Compare commits
42
Commits
5d5161da56
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddeab06b45 | ||
|
|
9d5f347ffb | ||
|
|
340d504ac8 | ||
|
|
581b1d77ef | ||
|
|
d55b04d4b8 | ||
|
|
eca56fab5c | ||
|
|
1fd03bf614 | ||
|
|
ca45b97c60 | ||
|
|
d2c30da602 | ||
|
|
9cdb78b9b8 | ||
|
|
a2259848dc | ||
|
|
1522ef0355 | ||
|
|
f10bc9e49c | ||
|
|
37af80c0be | ||
|
|
caf9fceb7a | ||
|
|
e2c33d6b29 | ||
|
|
87e6279dec | ||
|
|
5c48b029ca | ||
|
|
79dd81240d | ||
|
|
36f888931d | ||
|
|
ea35a30198 | ||
|
|
f7274e49ad | ||
|
|
6b433855e1 | ||
|
|
4429147434 | ||
|
|
5dc68b2d5a | ||
|
|
ea8fcb9db4 | ||
|
|
4d5738343f | ||
|
|
d606196e06 | ||
|
|
738338932f | ||
|
|
f71aedf2b5 | ||
|
|
e2869ffb47 | ||
|
|
6cbfd24bd6 | ||
|
|
095a7301e5 | ||
|
|
6907ec5183 | ||
|
|
a019910d61 | ||
|
|
4ffcaf18cd | ||
|
|
5a34c114c6 | ||
|
|
13cafffa5d | ||
|
|
913fc90585 | ||
|
|
7b397c3146 | ||
|
|
dfa4250b30 | ||
|
|
89ca0fe370 |
@@ -1,4 +1,4 @@
|
||||
# Portfolio New
|
||||
# Portfolio New (Gitea Test)
|
||||
|
||||
Bu monorepo, Next.js tabanlı portfolyo web uygulaması ve Electron ile geliştirilen veri yönetim panelini bir arada sunar.
|
||||
|
||||
@@ -75,6 +75,16 @@ pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Canlı medya kiti verileri
|
||||
|
||||
Medya kiti, YouTube kanalının herkese açık abone, toplam görüntülenme ve video
|
||||
sayılarını YouTube Data API üzerinden saatlik olarak yenileyebilir. Yayın ortamına
|
||||
`YOUTUBE_API_KEY` eklemek yeterlidir. İsteğe bağlı olarak `YOUTUBE_CHANNEL_ID`
|
||||
tanımlanabilir; tanımlanmadığında `@poyrazavsever` kullanıcı adı kullanılır.
|
||||
|
||||
API anahtarı yoksa veya YouTube isteği başarısız olursa sayfa, `data/media-kit.ts`
|
||||
içindeki son doğrulanmış YouTube Studio verilerine otomatik olarak geri döner.
|
||||
|
||||
## Electron Data Panel Komutları
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import { AboutContent } from "@/components/about-content";
|
||||
import { PersonJsonLd } from "@/components/json-ld";
|
||||
import { ProfilePageJsonLd } from "@/components/json-ld";
|
||||
import { getLocalizedUrl, getStaticPageMetadata } from "@/lib/seo";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({ locale, page: "about", path: "/about" });
|
||||
}
|
||||
|
||||
export default async function AboutPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const siteLocale = locale === "en" ? "en" : "tr";
|
||||
const t = await getTranslations({
|
||||
locale: siteLocale,
|
||||
namespace: "Seo.about",
|
||||
});
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<PersonJsonLd
|
||||
<ProfilePageJsonLd
|
||||
pageUrl={getLocalizedUrl(siteLocale, "/about")}
|
||||
pageName={t("title")}
|
||||
description={t("description")}
|
||||
locale={siteLocale}
|
||||
name="Poyraz Avsever"
|
||||
jobTitle="Fullstack Developer"
|
||||
sameAs={[
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { ReferencesDetailContent } from "@/components/references-detail-content";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({
|
||||
locale,
|
||||
page: "references",
|
||||
path: "/about/references",
|
||||
});
|
||||
}
|
||||
|
||||
export default function AboutReferencesPage() {
|
||||
return <ReferencesDetailContent />;
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { VolunteerCommunityContent } from "@/components/volunteer-community-content";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({
|
||||
locale,
|
||||
page: "volunteerCommunity",
|
||||
path: "/about/volunteer-community",
|
||||
});
|
||||
}
|
||||
|
||||
export default function AboutVolunteerCommunityPage() {
|
||||
return <VolunteerCommunityContent />;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BlogDetailContent } from "@/components/blog-detail-content";
|
||||
import { ArticleJsonLd } from "@/components/json-ld";
|
||||
import { getBlogDetailBySlug, getBlogTranslations } from "@/data/blog-detail";
|
||||
import { isNewsletterCategory } from "@/data/blog";
|
||||
import {
|
||||
createAlternates,
|
||||
getLocalizedUrl,
|
||||
type SiteLocale,
|
||||
} from "@/lib/seo";
|
||||
|
||||
type AgendaDetailPageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: AgendaDetailPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getBlogDetailBySlug(slug);
|
||||
|
||||
if (!post || post.lang !== locale || !isNewsletterCategory(post.category)) {
|
||||
return { title: locale === "en" ? "Agenda post not found" : "Gündem yazısı bulunamadı" };
|
||||
}
|
||||
|
||||
const siteLocale = locale as SiteLocale;
|
||||
const translations = await getBlogTranslations(post);
|
||||
const paths = Object.fromEntries(
|
||||
translations.map((translation) => [
|
||||
translation.lang,
|
||||
`/agenda/${translation.slug}`,
|
||||
]),
|
||||
);
|
||||
const url = getLocalizedUrl(siteLocale, `/agenda/${post.slug}`);
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
alternates: createAlternates(siteLocale, paths),
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
url,
|
||||
type: "article",
|
||||
siteName: "Poyraz Avsever",
|
||||
locale: locale === "en" ? "en_US" : "tr_TR",
|
||||
alternateLocale:
|
||||
translations.length > 1
|
||||
? [locale === "en" ? "tr_TR" : "en_US"]
|
||||
: undefined,
|
||||
publishedTime: post.date,
|
||||
authors: [post.author],
|
||||
images: [
|
||||
{
|
||||
url: post.coverImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: [post.coverImage],
|
||||
creator: "@poyrazavsever",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AgendaDetailPage({ params }: AgendaDetailPageProps) {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getBlogDetailBySlug(slug);
|
||||
|
||||
if (!post || post.lang !== locale || !isNewsletterCategory(post.category)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const url = getLocalizedUrl(
|
||||
locale as SiteLocale,
|
||||
`/agenda/${post.slug}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArticleJsonLd
|
||||
title={post.title}
|
||||
description={post.excerpt}
|
||||
url={url}
|
||||
image={post.coverImage}
|
||||
datePublished={post.date}
|
||||
authorName={post.author}
|
||||
locale={locale as SiteLocale}
|
||||
/>
|
||||
<BlogDetailContent post={post} section="agenda" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { BlogContent } from "@/components/blog-content";
|
||||
import { getAgendaPageData } from "@/data/blog";
|
||||
import { createPageMetadata } from "@/lib/seo";
|
||||
|
||||
type AgendaPageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams?: Promise<{
|
||||
page?: string | string[];
|
||||
search?: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: AgendaPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Agenda" });
|
||||
|
||||
return createPageMetadata({
|
||||
locale: locale === "en" ? "en" : "tr",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
path: "/agenda",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AgendaPage({ params, searchParams }: AgendaPageProps) {
|
||||
const { locale } = await params;
|
||||
const resolved = searchParams ? await searchParams : undefined;
|
||||
const pageParam = Array.isArray(resolved?.page) ? resolved.page[0] : resolved?.page;
|
||||
const searchParam = Array.isArray(resolved?.search)
|
||||
? resolved.search[0]
|
||||
: resolved?.search;
|
||||
const page = Number(pageParam ?? "1");
|
||||
const currentPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const data = await getAgendaPageData(locale, currentPage, 12, searchParam);
|
||||
|
||||
return <BlogContent data={data} section="agenda" />;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AnimationSourceDetailContent } from "@/components/animation-source-detail-content";
|
||||
import { ArticleJsonLd } from "@/components/json-ld";
|
||||
import {
|
||||
getAnimationSourceBySlug,
|
||||
listAnimationSources,
|
||||
} from "@/data/animation-sources";
|
||||
import {
|
||||
createAlternates,
|
||||
getAbsoluteUrl,
|
||||
getLocalizedUrl,
|
||||
type SiteLocale,
|
||||
} from "@/lib/seo";
|
||||
|
||||
type AnimationSourceDetailPageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const sources = await listAnimationSources();
|
||||
return sources.map((source) => ({
|
||||
locale: source.lang,
|
||||
slug: source.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: AnimationSourceDetailPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const source = await getAnimationSourceBySlug(slug, locale);
|
||||
|
||||
if (!source) {
|
||||
return { title: locale === "en" ? "Source not found" : "Kaynak bulunamadı" };
|
||||
}
|
||||
|
||||
const siteLocale = locale as SiteLocale;
|
||||
const path = `/animation-sources/${source.slug}`;
|
||||
const url = getLocalizedUrl(siteLocale, path);
|
||||
const socialImageUrl = getAbsoluteUrl(source.coverImage);
|
||||
|
||||
return {
|
||||
title: source.title,
|
||||
description: source.excerpt,
|
||||
alternates: createAlternates(siteLocale, { tr: path, en: path }),
|
||||
openGraph: {
|
||||
title: source.title,
|
||||
description: source.excerpt,
|
||||
url,
|
||||
siteName: "Poyraz Avsever",
|
||||
type: "article",
|
||||
locale: locale === "en" ? "en_US" : "tr_TR",
|
||||
alternateLocale: locale === "en" ? ["tr_TR"] : ["en_US"],
|
||||
publishedTime: source.date,
|
||||
authors: [source.author],
|
||||
images: [
|
||||
{
|
||||
url: socialImageUrl,
|
||||
type: "image/webp",
|
||||
width: 480,
|
||||
height: 480,
|
||||
alt: source.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: source.title,
|
||||
description: source.excerpt,
|
||||
creator: "@poyrazavsever",
|
||||
images: [
|
||||
{
|
||||
url: socialImageUrl,
|
||||
alt: source.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AnimationSourceDetailPage({
|
||||
params,
|
||||
}: AnimationSourceDetailPageProps) {
|
||||
const { locale, slug } = await params;
|
||||
const source = await getAnimationSourceBySlug(slug, locale);
|
||||
|
||||
if (!source) notFound();
|
||||
|
||||
const url = getLocalizedUrl(
|
||||
locale as SiteLocale,
|
||||
`/animation-sources/${source.slug}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArticleJsonLd
|
||||
title={source.title}
|
||||
description={source.excerpt}
|
||||
url={url}
|
||||
image={source.coverImage}
|
||||
datePublished={source.date}
|
||||
authorName={source.author}
|
||||
locale={locale === "en" ? "en" : "tr"}
|
||||
/>
|
||||
<AnimationSourceDetailContent source={source} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AnimationSourcesContent } from "@/components/animation-sources-content";
|
||||
import { listAnimationSources } from "@/data/animation-sources";
|
||||
import {
|
||||
createAlternates,
|
||||
getAbsoluteUrl,
|
||||
getLocalizedUrl,
|
||||
type SiteLocale,
|
||||
} from "@/lib/seo";
|
||||
|
||||
type AnimationSourcesPageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: AnimationSourcesPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "AnimationSources" });
|
||||
const siteLocale = locale as SiteLocale;
|
||||
const url = getLocalizedUrl(siteLocale, "/animation-sources");
|
||||
const socialImageUrl = getAbsoluteUrl("/og.png");
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
alternates: createAlternates(siteLocale, {
|
||||
tr: "/animation-sources",
|
||||
en: "/animation-sources",
|
||||
}),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
url,
|
||||
siteName: "Poyraz Avsever",
|
||||
type: "website",
|
||||
locale: locale === "en" ? "en_US" : "tr_TR",
|
||||
images: [
|
||||
{
|
||||
url: socialImageUrl,
|
||||
type: "image/png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("title"),
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
creator: "@poyrazavsever",
|
||||
images: [{ url: socialImageUrl, alt: t("title") }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AnimationSourcesPage({
|
||||
params,
|
||||
}: AnimationSourcesPageProps) {
|
||||
const { locale } = await params;
|
||||
const [sources, t] = await Promise.all([
|
||||
listAnimationSources(locale),
|
||||
getTranslations({ locale, namespace: "AnimationSources" }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<AnimationSourcesContent
|
||||
sources={sources}
|
||||
emptyLabel={t("empty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, permanentRedirect } from "next/navigation";
|
||||
import { Metadata } from "next";
|
||||
import { BlogDetailContent } from "@/components/blog-detail-content";
|
||||
import { ArticleJsonLd } from "@/components/json-ld";
|
||||
import { getBlogDetailBySlug } from "@/data/blog-detail";
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
|
||||
import { getBlogDetailBySlug, getBlogTranslations } from "@/data/blog-detail";
|
||||
import { isNewsletterCategory } from "@/data/blog";
|
||||
import {
|
||||
createAlternates,
|
||||
getLocalizedUrl,
|
||||
type SiteLocale,
|
||||
} from "@/lib/seo";
|
||||
|
||||
type BlogDetailPageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
@@ -20,16 +24,31 @@ export async function generateMetadata({ params }: BlogDetailPageProps): Promise
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${SITE_URL}/blog/${post.slug}`;
|
||||
const siteLocale = locale as SiteLocale;
|
||||
const translations = await getBlogTranslations(post);
|
||||
const paths = Object.fromEntries(
|
||||
translations.map((translation) => [
|
||||
translation.lang,
|
||||
`/blog/${translation.slug}`,
|
||||
]),
|
||||
);
|
||||
const url = getLocalizedUrl(siteLocale, `/blog/${post.slug}`);
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
alternates: createAlternates(siteLocale, paths),
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
url,
|
||||
type: "article",
|
||||
siteName: "Poyraz Avsever",
|
||||
locale: locale === "en" ? "en_US" : "tr_TR",
|
||||
alternateLocale:
|
||||
translations.length > 1
|
||||
? [locale === "en" ? "tr_TR" : "en_US"]
|
||||
: undefined,
|
||||
publishedTime: post.date,
|
||||
authors: [post.author],
|
||||
images: [
|
||||
@@ -46,6 +65,7 @@ export async function generateMetadata({ params }: BlogDetailPageProps): Promise
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: [post.coverImage],
|
||||
creator: "@poyrazavsever",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -58,15 +78,20 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (isNewsletterCategory(post.category)) {
|
||||
permanentRedirect(`/${locale}/agenda/${post.slug}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArticleJsonLd
|
||||
title={post.title}
|
||||
description={post.excerpt}
|
||||
url={`${SITE_URL}/blog/${post.slug}`}
|
||||
url={getLocalizedUrl(locale as SiteLocale, `/blog/${post.slug}`)}
|
||||
image={post.coverImage}
|
||||
datePublished={post.date}
|
||||
authorName={post.author}
|
||||
locale={locale as SiteLocale}
|
||||
/>
|
||||
<BlogDetailContent post={post} />
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { BlogContent } from "@/components/blog-content";
|
||||
import { getBlogPageData } from "@/data/blog";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
type BlogPageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams?: Promise<{ page?: string | string[]; category?: string | string[]; search?: string | string[] }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: BlogPageProps) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({ locale, page: "blog", path: "/blog" });
|
||||
}
|
||||
|
||||
export default async function BlogPage({ params, searchParams }: BlogPageProps) {
|
||||
const { locale } = await params;
|
||||
const resolved = searchParams ? await searchParams : undefined;
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { ContactContent } from "@/components/contact-content";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({
|
||||
locale,
|
||||
page: "contact",
|
||||
path: "/contact",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContactPage() {
|
||||
return <ContactContent />;
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { ContentContent } from "@/components/content-content";
|
||||
import { YOUTUBE_VIDEO_LINKS } from "@/data/youtube-videos";
|
||||
import { X_JAVASCRIPT_ANATOMY_VIDEOS } from "@/data/x-videos";
|
||||
import { getPdfNotes } from "@/lib/content-page";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({
|
||||
locale,
|
||||
page: "content",
|
||||
path: "/content",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ContentPage() {
|
||||
const pdfFiles = await getPdfNotes();
|
||||
@@ -9,6 +24,7 @@ export default async function ContentPage() {
|
||||
<ContentContent
|
||||
youtubeLinks={YOUTUBE_VIDEO_LINKS}
|
||||
pdfFiles={pdfFiles}
|
||||
xVideos={X_JAVASCRIPT_ANATOMY_VIDEOS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { GalleryContent } from "@/components/gallery-content";
|
||||
import { GALLERY_IMAGES } from "@/data/gallery";
|
||||
import type { Metadata } from "next";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Galeri",
|
||||
description: "Poyraz Avsever'in tasarımları, projeleri ve görsel içeriklerinden oluşan galeri portföyü.",
|
||||
};
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({
|
||||
locale,
|
||||
page: "gallery",
|
||||
path: "/gallery",
|
||||
});
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
return <GalleryContent images={GALLERY_IMAGES} />;
|
||||
|
||||
+58
-13
@@ -7,6 +7,10 @@ import "../globals.css";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { GoogleAnalytics } from "@/components/google-analytics";
|
||||
import { PoyrazBottomRightFollower } from "@/components/poyraz-bottom-right-follower";
|
||||
import { listAnimationSources } from "@/data/animation-sources";
|
||||
import { getHomeBlogNews, getLatestAgendaArticle } from "@/data/blog";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -21,40 +25,47 @@ export async function generateMetadata({
|
||||
const description = t("description");
|
||||
|
||||
return {
|
||||
metadataBase: new URL("https://poyrazavsever.com"),
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: title,
|
||||
template: titleTemplate,
|
||||
},
|
||||
description: description,
|
||||
applicationName: "Poyraz Avsever Portfolyo",
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/logo/logo.png", type: "image/jpeg" },
|
||||
{ url: "/logo/logo-96.webp", type: "image/webp", sizes: "96x96" },
|
||||
],
|
||||
shortcut: "/favicon.ico",
|
||||
apple: "/logo/logo.png",
|
||||
apple: "/logo/apple-touch-icon.png",
|
||||
},
|
||||
authors: [{ name: "Poyraz Avsever" }],
|
||||
creator: "Poyraz Avsever",
|
||||
publisher: "Poyraz Avsever",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: locale === "tr" ? "tr_TR" : "en_US",
|
||||
url: "https://poyrazavsever.com",
|
||||
siteName: title,
|
||||
title: title,
|
||||
description: description,
|
||||
images: [
|
||||
{
|
||||
url: "/logo/logo.png",
|
||||
url: "/og.png",
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
alt: "Poyraz Avsever Logo",
|
||||
height: 630,
|
||||
alt: t("socialImageAlt"),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -62,7 +73,7 @@ export async function generateMetadata({
|
||||
card: "summary_large_image",
|
||||
title: title,
|
||||
description: description,
|
||||
images: ["/logo/logo.png"],
|
||||
images: ["/og.png"],
|
||||
creator: "@poyrazavsever",
|
||||
},
|
||||
keywords: [
|
||||
@@ -107,14 +118,48 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
// Provide messages for NextIntlClientProvider
|
||||
const messages = await getMessages();
|
||||
const [messages, animationSources, latestAgendaArticle, latestPosts] =
|
||||
await Promise.all([
|
||||
getMessages(),
|
||||
listAnimationSources(locale),
|
||||
getLatestAgendaArticle(locale),
|
||||
getHomeBlogNews(locale, 1),
|
||||
]);
|
||||
const animationSourceSearchItems = animationSources.map((source) => ({
|
||||
slug: source.slug,
|
||||
title: source.title,
|
||||
excerpt: source.excerpt,
|
||||
platform: source.platform,
|
||||
tools: source.tools,
|
||||
}));
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<GoogleAnalytics />
|
||||
<AppShell>{children}</AppShell>
|
||||
<AppShell
|
||||
animationSources={animationSourceSearchItems}
|
||||
latestAgenda={
|
||||
latestAgendaArticle
|
||||
? {
|
||||
title: latestAgendaArticle.title,
|
||||
href: latestAgendaArticle.href,
|
||||
}
|
||||
: null
|
||||
}
|
||||
latestPost={
|
||||
latestPosts[0]
|
||||
? {
|
||||
title: latestPosts[0].title,
|
||||
href: latestPosts[0].href,
|
||||
}
|
||||
: null
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
<PoyrazBottomRightFollower />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+31
-31
@@ -1,36 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LinksContent } from "@/components/links-content";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Links",
|
||||
description:
|
||||
"Poyraz Avsever'in sosyal medya, portfolyo ve içerik bağlantılarına tek sayfadan ulaş.",
|
||||
alternates: {
|
||||
canonical: "/links",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Poyraz Avsever | Links",
|
||||
description:
|
||||
"Poyraz Avsever'in sosyal medya, portfolyo ve içerik bağlantılarına tek sayfadan ulaş.",
|
||||
url: "https://poyrazavsever.com/links",
|
||||
images: [
|
||||
{
|
||||
url: "/logo/cover.png",
|
||||
width: 1536,
|
||||
height: 512,
|
||||
alt: "Poyraz Avsever Links sayfası kapak görseli",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Poyraz Avsever | Links",
|
||||
description:
|
||||
"Poyraz Avsever'in sosyal medya, portfolyo ve içerik bağlantılarına tek sayfadan ulaş.",
|
||||
images: ["/logo/cover.png"],
|
||||
},
|
||||
type LinksPageProps = {
|
||||
searchParams?: Promise<{
|
||||
category?: string | string[];
|
||||
query?: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export default function LinksPage() {
|
||||
return <LinksContent />;
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({ locale, page: "links", path: "/links" });
|
||||
}
|
||||
|
||||
function firstParam(value?: string | string[]) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export default async function LinksPage({ searchParams }: LinksPageProps) {
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const initialCategory = firstParam(resolvedSearchParams?.category);
|
||||
const initialQuery = firstParam(resolvedSearchParams?.query);
|
||||
|
||||
return (
|
||||
<LinksContent
|
||||
key={`${initialCategory ?? "all"}-${initialQuery ?? ""}`}
|
||||
initialCategory={initialCategory}
|
||||
initialQuery={initialQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MediaKitContent } from "@/components/media-kit-content";
|
||||
import type { MediaKitLocale } from "@/data/media-kit";
|
||||
import { createPageMetadata } from "@/lib/seo";
|
||||
import { getYouTubeChannelStats } from "@/lib/youtube-channel-stats";
|
||||
|
||||
type MediaKitPageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -13,25 +15,14 @@ export async function generateMetadata({
|
||||
const { locale } = await params;
|
||||
const isTurkish = locale === "tr";
|
||||
|
||||
return {
|
||||
return createPageMetadata({
|
||||
locale: isTurkish ? "tr" : "en",
|
||||
title: isTurkish ? "Medya Kiti" : "Media Kit",
|
||||
description: isTurkish
|
||||
? "Poyraz Avsever sponsorluk ve marka iş birlikleri medya kiti."
|
||||
: "Poyraz Avsever media kit for sponsorships and brand partnerships.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
nocache: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: false,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
alternates: {
|
||||
canonical: null,
|
||||
},
|
||||
};
|
||||
path: "/media-kit",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function MediaKitPage({ params }: MediaKitPageProps) {
|
||||
@@ -41,5 +32,12 @@ export default async function MediaKitPage({ params }: MediaKitPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <MediaKitContent locale={locale as MediaKitLocale} />;
|
||||
const youtubeStats = await getYouTubeChannelStats();
|
||||
|
||||
return (
|
||||
<MediaKitContent
|
||||
locale={locale as MediaKitLocale}
|
||||
youtubeStats={youtubeStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-1
@@ -1,8 +1,24 @@
|
||||
import { HomeHero } from "@/components/home-hero";
|
||||
import { HomeProjectsSection } from "@/components/home-projects-section";
|
||||
import { HomeTechnologyStack } from "@/components/home-technology-stack";
|
||||
import { HomeVideosSection } from "@/components/home-videos-section";
|
||||
import { ReferencesSection } from "@/components/references-section";
|
||||
import { getHomeBlogNews } from "@/data/blog";
|
||||
import { getStaticPageMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({
|
||||
locale,
|
||||
page: "home",
|
||||
path: "/",
|
||||
absoluteTitle: true,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
params,
|
||||
@@ -14,7 +30,9 @@ export default async function Home({
|
||||
|
||||
return (
|
||||
<section className="flex h-full flex-col overflow-y-auto overflow-x-hidden">
|
||||
<HomeHero news={homeNews} />
|
||||
<HomeHero news={homeNews}>
|
||||
<HomeTechnologyStack />
|
||||
</HomeHero>
|
||||
<HomeProjectsSection />
|
||||
<ReferencesSection />
|
||||
<HomeVideosSection />
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProjectCaseStudyContent } from "@/components/project-case-study-content";
|
||||
import { ProjectCaseStudyJsonLd } from "@/components/json-ld";
|
||||
import {
|
||||
getProjectCaseStudy,
|
||||
PROJECT_CASE_STUDY_SLUGS,
|
||||
type ProjectCaseStudyLocale,
|
||||
} from "@/data/project-case-studies";
|
||||
import {
|
||||
createAlternates,
|
||||
getAbsoluteUrl,
|
||||
getLocalizedUrl,
|
||||
} from "@/lib/seo";
|
||||
|
||||
type ProjectCaseStudyPageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return (["tr", "en"] as ProjectCaseStudyLocale[]).flatMap((locale) =>
|
||||
PROJECT_CASE_STUDY_SLUGS.map((slug) => ({ locale, slug })),
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ProjectCaseStudyPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const siteLocale: ProjectCaseStudyLocale = locale === "en" ? "en" : "tr";
|
||||
const project = getProjectCaseStudy(slug, siteLocale);
|
||||
|
||||
if (!project) {
|
||||
return {
|
||||
title: siteLocale === "en" ? "Project not found" : "Proje bulunamadı",
|
||||
};
|
||||
}
|
||||
|
||||
const path = `/projects/${project.slug}`;
|
||||
const url = getLocalizedUrl(siteLocale, path);
|
||||
const socialImageUrl = getAbsoluteUrl(project.image);
|
||||
|
||||
return {
|
||||
title: project.title,
|
||||
description: project.summary,
|
||||
alternates: createAlternates(siteLocale, { tr: path, en: path }),
|
||||
openGraph: {
|
||||
title: project.title,
|
||||
description: project.summary,
|
||||
url,
|
||||
siteName: "Poyraz Avsever",
|
||||
type: "website",
|
||||
locale: siteLocale === "en" ? "en_US" : "tr_TR",
|
||||
alternateLocale: siteLocale === "en" ? ["tr_TR"] : ["en_US"],
|
||||
images: [
|
||||
{
|
||||
url: socialImageUrl,
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
alt: project.screenshotAlt,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: project.title,
|
||||
description: project.summary,
|
||||
creator: "@poyrazavsever",
|
||||
images: [{ url: socialImageUrl, alt: project.screenshotAlt }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectCaseStudyPage({
|
||||
params,
|
||||
}: ProjectCaseStudyPageProps) {
|
||||
const { locale, slug } = await params;
|
||||
const siteLocale: ProjectCaseStudyLocale = locale === "en" ? "en" : "tr";
|
||||
const project = getProjectCaseStudy(slug, siteLocale);
|
||||
|
||||
if (!project) notFound();
|
||||
|
||||
const url = getLocalizedUrl(siteLocale, `/projects/${project.slug}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectCaseStudyJsonLd
|
||||
name={project.title}
|
||||
description={project.summary}
|
||||
url={url}
|
||||
liveUrl={project.liveUrl}
|
||||
image={project.image}
|
||||
locale={siteLocale}
|
||||
applicationCategory={project.applicationCategory}
|
||||
technologies={project.technologies}
|
||||
features={project.results.map((result) => result.description)}
|
||||
/>
|
||||
<ProjectCaseStudyContent project={project} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,62 @@
|
||||
import { ProjectsContent } from "@/components/projects-content";
|
||||
import { ProjectsJsonLd } from "@/components/json-ld";
|
||||
import {
|
||||
EXTENSIONS,
|
||||
FIGMA_TEMPLATES,
|
||||
MOBILE_APPS,
|
||||
WEB_APPS,
|
||||
} from "@/data/projects";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
import { getLocalizedUrl, getStaticPageMetadata } from "@/lib/seo";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return <ProjectsContent />;
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
return getStaticPageMetadata({
|
||||
locale,
|
||||
page: "projects",
|
||||
path: "/projects",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ProjectsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const siteLocale = locale === "en" ? "en" : "tr";
|
||||
const t = await getTranslations({
|
||||
locale: siteLocale,
|
||||
namespace: "Seo.projects",
|
||||
});
|
||||
const projects = [
|
||||
...WEB_APPS,
|
||||
...MOBILE_APPS,
|
||||
...EXTENSIONS,
|
||||
...FIGMA_TEMPLATES,
|
||||
].map((project) => ({
|
||||
name: getLocalizedValue(project.title, siteLocale),
|
||||
description: getLocalizedValue(project.description, siteLocale),
|
||||
image: project.image,
|
||||
url: project.href,
|
||||
technologies: project.technologies,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectsJsonLd
|
||||
name={t("title")}
|
||||
description={t("description")}
|
||||
url={getLocalizedUrl(siteLocale, "/projects")}
|
||||
locale={siteLocale}
|
||||
projects={projects}
|
||||
/>
|
||||
<ProjectsContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,29 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--poyraz-font-secondary: "Nunito", ui-rounded, "Avenir Next", system-ui, sans-serif;
|
||||
--github-contribution-empty: #ebedf0;
|
||||
}
|
||||
|
||||
.dark,
|
||||
[data-poyraz-theme="dark"] {
|
||||
--github-contribution-empty: #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-poyraz-theme="light"] {
|
||||
--poyraz-background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
html[data-poyraz-theme="light"] body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.github-contribution-empty {
|
||||
fill: var(--github-contribution-empty);
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0%); }
|
||||
100% { transform: translateX(-50%); }
|
||||
@@ -27,4 +46,39 @@
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes sleepy-z {
|
||||
0%, 12% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0.45rem, 0) scale(0.82) rotate(-8deg);
|
||||
}
|
||||
28%, 68% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(-0.3rem, -0.85rem, 0) scale(1.12) rotate(4deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-sleepy-z {
|
||||
animation: sleepy-z 2.8s ease-in-out infinite;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
[data-sleepy-z="2"] {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
[data-sleepy-z="3"] {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-sleepy-z {
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
@@ -8,7 +7,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/api/", "/_next/", "/media-kit", "/tr/media-kit", "/en/media-kit"],
|
||||
disallow: ["/api/", "/_next/"],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAllBlogArticles } from "@/data/blog";
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
const FEED_PATH = "/rss.xml";
|
||||
const FEED_TITLE = "Poyraz Avsever Blog";
|
||||
const FEED_DESCRIPTION =
|
||||
|
||||
+147
-68
@@ -1,75 +1,154 @@
|
||||
import { listBlogDetails } from "@/data/blog-detail";
|
||||
import type { MetadataRoute } from "next";
|
||||
import { listAnimationSources } from "@/data/animation-sources";
|
||||
import { isNewsletterCategory } from "@/data/blog";
|
||||
import { listBlogDetails } from "@/data/blog-detail";
|
||||
import { listProjectCaseStudies } from "@/data/project-case-studies";
|
||||
import {
|
||||
getAbsoluteUrl,
|
||||
getLocalizedUrl,
|
||||
type SiteLocale,
|
||||
} from "@/lib/seo";
|
||||
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
|
||||
const LOCALES: SiteLocale[] = ["tr", "en"];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const posts = await listBlogDetails();
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/about`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/about/references`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/about/volunteer-community`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/projects`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/content`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/contact`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/links`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.4,
|
||||
},
|
||||
];
|
||||
|
||||
const blogRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: post.date ? new Date(post.date) : new Date(),
|
||||
const STATIC_ROUTES = [
|
||||
{ path: "/", changeFrequency: "weekly", priority: 1 },
|
||||
{ path: "/about", changeFrequency: "monthly", priority: 0.8 },
|
||||
{ path: "/about/references", changeFrequency: "monthly", priority: 0.5 },
|
||||
{
|
||||
path: "/about/volunteer-community",
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{ path: "/blog", changeFrequency: "weekly", priority: 0.9 },
|
||||
{ path: "/agenda", changeFrequency: "weekly", priority: 0.9 },
|
||||
{ path: "/projects", changeFrequency: "monthly", priority: 0.8 },
|
||||
{ path: "/content", changeFrequency: "weekly", priority: 0.7 },
|
||||
{ path: "/gallery", changeFrequency: "monthly", priority: 0.6 },
|
||||
{ path: "/contact", changeFrequency: "yearly", priority: 0.5 },
|
||||
{ path: "/media-kit", changeFrequency: "monthly", priority: 0.6 },
|
||||
{ path: "/links", changeFrequency: "monthly", priority: 0.4 },
|
||||
{
|
||||
path: "/animation-sources",
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
}));
|
||||
},
|
||||
] as const;
|
||||
|
||||
return [...staticRoutes, ...blogRoutes];
|
||||
function toValidDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
function getLanguageLinks(paths: Partial<Record<SiteLocale, string>>) {
|
||||
const languages: Record<string, string> = {};
|
||||
|
||||
if (paths.tr) languages["tr-TR"] = getLocalizedUrl("tr", paths.tr);
|
||||
if (paths.en) languages["en-US"] = getLocalizedUrl("en", paths.en);
|
||||
languages["x-default"] = paths.tr
|
||||
? getLocalizedUrl("tr", paths.tr)
|
||||
: getLocalizedUrl("en", paths.en!);
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const [posts, animationSources] = await Promise.all([
|
||||
listBlogDetails(),
|
||||
listAnimationSources(),
|
||||
]);
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = STATIC_ROUTES.flatMap((route) => {
|
||||
const paths = { tr: route.path, en: route.path };
|
||||
const languages = getLanguageLinks(paths);
|
||||
|
||||
return LOCALES.map((locale) => ({
|
||||
url: getLocalizedUrl(locale, route.path),
|
||||
changeFrequency: route.changeFrequency,
|
||||
priority: route.priority,
|
||||
alternates: { languages },
|
||||
}));
|
||||
});
|
||||
|
||||
const postGroups = new Map<string, typeof posts>();
|
||||
for (const post of posts) {
|
||||
const key = `${post.category.toLocaleLowerCase()}:${post.coverImage}`;
|
||||
postGroups.set(key, [...(postGroups.get(key) ?? []), post]);
|
||||
}
|
||||
|
||||
const blogRoutes: MetadataRoute.Sitemap = posts.map((post) => {
|
||||
const section = isNewsletterCategory(post.category) ? "agenda" : "blog";
|
||||
const key = `${post.category.toLocaleLowerCase()}:${post.coverImage}`;
|
||||
const translations = postGroups.get(key) ?? [post];
|
||||
const paths = Object.fromEntries(
|
||||
translations.map((translation) => [
|
||||
translation.lang,
|
||||
`/${section}/${translation.slug}`,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
url: getLocalizedUrl(post.lang, `/${section}/${post.slug}`),
|
||||
lastModified: toValidDate(post.date),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
alternates: { languages: getLanguageLinks(paths) },
|
||||
images: [getAbsoluteUrl(post.coverImage)],
|
||||
};
|
||||
});
|
||||
|
||||
const animationGroups = new Map<string, typeof animationSources>();
|
||||
for (const source of animationSources) {
|
||||
animationGroups.set(source.slug, [
|
||||
...(animationGroups.get(source.slug) ?? []),
|
||||
source,
|
||||
]);
|
||||
}
|
||||
|
||||
const animationSourceRoutes: MetadataRoute.Sitemap = animationSources.map(
|
||||
(source) => {
|
||||
const translations = animationGroups.get(source.slug) ?? [source];
|
||||
const paths = Object.fromEntries(
|
||||
translations.map((translation) => [
|
||||
translation.lang,
|
||||
`/animation-sources/${translation.slug}`,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
url: getLocalizedUrl(
|
||||
source.lang,
|
||||
`/animation-sources/${source.slug}`,
|
||||
),
|
||||
lastModified: toValidDate(source.date),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
alternates: { languages: getLanguageLinks(paths) },
|
||||
images: [getAbsoluteUrl(source.coverImage)],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const projectCaseStudyRoutes: MetadataRoute.Sitemap = LOCALES.flatMap(
|
||||
(locale) =>
|
||||
listProjectCaseStudies(locale).map((project) => {
|
||||
const path = `/projects/${project.slug}`;
|
||||
const paths = { tr: path, en: path };
|
||||
|
||||
return {
|
||||
url: getLocalizedUrl(locale, path),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.8,
|
||||
alternates: { languages: getLanguageLinks(paths) },
|
||||
images: [getAbsoluteUrl(project.image)],
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
...staticRoutes,
|
||||
...projectCaseStudyRoutes,
|
||||
...blogRoutes,
|
||||
...animationSourceRoutes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Badge, Card, Typography } from "poyraz-ui/atoms";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "poyraz-ui/molecules";
|
||||
import { ArticleToc } from "@/components/article-toc";
|
||||
import type { AnimationSource } from "@/data/animation-sources";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { slugifyMarkdownHeading } from "@/lib/markdown-headings";
|
||||
|
||||
type AnimationSourceDetailContentProps = {
|
||||
source: AnimationSource;
|
||||
};
|
||||
|
||||
function extractText(children: React.ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) return children.map(extractText).join("");
|
||||
if (children && typeof children === "object" && "props" in children) {
|
||||
return extractText(
|
||||
(children as React.ReactElement<{ children?: React.ReactNode }>).props
|
||||
.children,
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function copyToClipboard(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
function CopyableCodeBlock({
|
||||
code,
|
||||
language,
|
||||
}: {
|
||||
code: string;
|
||||
language: string;
|
||||
}) {
|
||||
const t = useTranslations("AnimationSources");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyToClipboard(code);
|
||||
setCopied(true);
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current);
|
||||
}
|
||||
resetTimerRef.current = window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayLanguage = language || "text";
|
||||
const highlighterLanguage = language === "prompt" ? "text" : displayLanguage;
|
||||
|
||||
return (
|
||||
<Card className="my-4 overflow-hidden rounded-sm border-border">
|
||||
<div className="flex h-9 items-center justify-between border-b border-border bg-muted/40 px-3">
|
||||
<span className="text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{displayLanguage}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
aria-label={copied ? t("copied") : t("copy")}
|
||||
>
|
||||
<Icon
|
||||
icon={copied ? "mdi:check" : "mdi:content-copy"}
|
||||
width={15}
|
||||
height={15}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={highlighterLanguage}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.65",
|
||||
}}
|
||||
wrapLongLines={false}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
||||
if (!src || !src.startsWith("/")) return null;
|
||||
|
||||
return (
|
||||
<Card className="my-4 overflow-hidden rounded-sm border-border">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
width={1200}
|
||||
height={1200}
|
||||
unoptimized={src.toLowerCase().endsWith(".gif")}
|
||||
sizes="(max-width: 768px) 100vw, 720px"
|
||||
className="h-auto w-full object-contain"
|
||||
/>
|
||||
{alt ? (
|
||||
<Typography
|
||||
variant="small"
|
||||
className="block border-t border-border px-3 py-2 text-center text-muted-foreground"
|
||||
>
|
||||
{alt}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimationSourceDetailContent({
|
||||
source,
|
||||
}: AnimationSourceDetailContentProps) {
|
||||
const t = useTranslations("AnimationSources");
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const [tocOpen, setTocOpen] = useState(false);
|
||||
const closeToc = useCallback(() => setTocOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId = 0;
|
||||
|
||||
const update = () => {
|
||||
rafId = 0;
|
||||
const bar = progressBarRef.current;
|
||||
if (!bar) return;
|
||||
|
||||
const scrollableHeight =
|
||||
document.documentElement.scrollHeight -
|
||||
document.documentElement.clientHeight;
|
||||
const progress =
|
||||
scrollableHeight <= 0 ? 100 : (window.scrollY / scrollableHeight) * 100;
|
||||
bar.style.width = `${Math.min(100, Math.max(0, progress))}%`;
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafId !== 0) return;
|
||||
rafId = window.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
update();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
if (rafId !== 0) window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-0 left-0 z-50 h-1 w-full bg-border/70">
|
||||
<div ref={progressBarRef} className="h-full w-0 bg-red-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<article className="space-y-6 rounded-sm border border-border p-5 md:p-8">
|
||||
<Link
|
||||
href="/animation-sources"
|
||||
className="inline-flex items-center gap-1.5 rounded-sm border border-border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon icon="mdi:arrow-left" width={15} height={15} />
|
||||
{t("back")}
|
||||
</Link>
|
||||
|
||||
<header className="space-y-3 border-b border-border pb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="rounded-sm">{source.platform}</Badge>
|
||||
{source.tools.map((tool) => (
|
||||
<Badge key={tool} variant="outline" className="rounded-sm">
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Typography variant="h2">{source.title}</Typography>
|
||||
<Typography variant="p" className="text-sm text-muted-foreground">
|
||||
{source.excerpt}
|
||||
</Typography>
|
||||
<Typography variant="small" className="block text-muted-foreground">
|
||||
{source.author} · {source.date}
|
||||
</Typography>
|
||||
</header>
|
||||
|
||||
<section className="min-w-0 space-y-5">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<Typography
|
||||
id={slugifyMarkdownHeading(extractText(children))}
|
||||
variant="h2"
|
||||
className="mt-10 mb-3 border-b border-border pb-3"
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<Typography
|
||||
id={slugifyMarkdownHeading(extractText(children))}
|
||||
variant="h3"
|
||||
className="mt-8 mb-2 scroll-mt-24 border-b border-border pb-2"
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<Typography
|
||||
id={slugifyMarkdownHeading(extractText(children))}
|
||||
variant="large"
|
||||
className="mt-6 mb-1 scroll-mt-24 text-foreground"
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
),
|
||||
p: ({ node, children }) => {
|
||||
const containsImage = node?.children.some(
|
||||
(child) =>
|
||||
child.type === "element" && child.tagName === "img",
|
||||
);
|
||||
|
||||
if (containsImage) {
|
||||
return <div className="my-4">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography
|
||||
variant="p"
|
||||
className="text-sm leading-7 text-foreground/85"
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-3 list-disc space-y-1.5 pl-5 text-sm leading-7 text-foreground/85">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="my-3 list-decimal space-y-1.5 pl-5 text-sm leading-7 text-foreground/85">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target={href?.startsWith("http") ? "_blank" : undefined}
|
||||
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className="text-red-600 underline decoration-red-600/30 underline-offset-2 transition-colors hover:decoration-red-600"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<Card className="my-4 rounded-sm border-border border-l-red-600 bg-muted/30 px-4 py-3">
|
||||
<div className="text-sm text-muted-foreground">{children}</div>
|
||||
</Card>
|
||||
),
|
||||
hr: () => <div className="my-6 border-t border-border" />,
|
||||
table: ({ children }) => (
|
||||
<Card className="my-4 overflow-x-auto rounded-sm border-border">
|
||||
<table className="min-w-full border-collapse text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</Card>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border-b border-border bg-muted/50 px-4 py-2.5 text-left text-xs font-semibold text-muted-foreground">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border-b border-border px-4 py-2.5 align-top text-sm">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
<MarkdownImage
|
||||
src={typeof src === "string" ? src : undefined}
|
||||
alt={typeof alt === "string" ? alt : undefined}
|
||||
/>
|
||||
),
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: ({ className, children }) => {
|
||||
const match = /language-([\w-]+)/.exec(className ?? "");
|
||||
if (!match) {
|
||||
return (
|
||||
<code className="rounded-sm border border-border/60 bg-muted/60 px-1.5 py-0.5 text-[13px] text-red-600">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyableCodeBlock
|
||||
language={match[1]}
|
||||
code={String(children).replace(/\n$/, "")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{source.markdown}
|
||||
</ReactMarkdown>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="hidden w-52 shrink-0 lg:block">
|
||||
<div className="sticky top-24">
|
||||
<ArticleToc markdown={source.markdown} title={t("toc")} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTocOpen(true)}
|
||||
className="fixed right-4 bottom-6 z-40 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-border bg-background shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
|
||||
aria-label={t("toc")}
|
||||
>
|
||||
<Icon icon="mdi:table-of-contents" width={22} height={22} className="text-red-600" />
|
||||
</button>
|
||||
|
||||
{tocOpen ? (
|
||||
<div className="fixed inset-0 z-50 lg:hidden" onClick={closeToc}>
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
<div
|
||||
className="absolute right-0 bottom-0 left-0 max-h-[60vh] overflow-y-auto rounded-t-lg border-t border-border bg-background p-5 shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Typography variant="large">{t("toc")}</Typography>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeToc}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t("closeToc")}
|
||||
>
|
||||
<Icon icon="mdi:close" width={18} height={18} />
|
||||
</button>
|
||||
</div>
|
||||
<ArticleToc
|
||||
markdown={source.markdown}
|
||||
title={t("toc")}
|
||||
onNavigate={closeToc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Card, Typography } from "poyraz-ui/atoms";
|
||||
import { NewsCard } from "poyraz-ui/molecules";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import type { AnimationSource } from "@/data/animation-sources";
|
||||
|
||||
type AnimationSourcesContentProps = {
|
||||
sources: AnimationSource[];
|
||||
emptyLabel: string;
|
||||
};
|
||||
|
||||
export function AnimationSourcesContent({
|
||||
sources,
|
||||
emptyLabel,
|
||||
}: AnimationSourcesContentProps) {
|
||||
return (
|
||||
<section>
|
||||
{sources.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source) => (
|
||||
<Link
|
||||
key={`${source.lang}-${source.slug}`}
|
||||
href={`/animation-sources/${source.slug}`}
|
||||
data-animation-source-card
|
||||
className="block min-w-0"
|
||||
>
|
||||
<NewsCard
|
||||
image={source.coverImage}
|
||||
category={source.platform}
|
||||
title={source.title}
|
||||
date={source.date}
|
||||
className="w-full rounded-sm border-border [&>div]:min-h-32 [&>div>div:first-child]:w-32 sm:[&>div>div:first-child]:w-40 [&_h3]:text-base"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="rounded-sm border-border p-6 text-center">
|
||||
<Typography variant="p" className="text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+40
-23
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { usePathname } from "@/i18n/routing";
|
||||
import { AnnouncementBar } from "poyraz-ui/organisms";
|
||||
import { SiteNavbar } from "@/components/site-navbar";
|
||||
import { NekoFollower } from "@/components/neko-follower";
|
||||
@@ -9,6 +8,12 @@ import { ANNOUNCEMENT_ITEMS, ENABLE_NEKO_FOLLOWER } from "@/data/site-settings";
|
||||
import { useLocale } from "next-intl";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { AnimationSourceSearchItem } from "@/lib/command-palette-links";
|
||||
import {
|
||||
LayoutLeftPromoRail,
|
||||
LayoutRightPromoRail,
|
||||
type LayoutContentPromo,
|
||||
} from "@/components/layout-promo-rails";
|
||||
|
||||
const AtaturkWidgetModal = dynamic(
|
||||
() => import("@/components/ataturk-widget-modal").then((mod) => mod.AtaturkWidgetModal),
|
||||
@@ -17,6 +22,9 @@ const AtaturkWidgetModal = dynamic(
|
||||
|
||||
type AppShellProps = {
|
||||
children: React.ReactNode;
|
||||
animationSources: AnimationSourceSearchItem[];
|
||||
latestAgenda: LayoutContentPromo | null;
|
||||
latestPost: LayoutContentPromo | null;
|
||||
};
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
@@ -30,15 +38,15 @@ function getInitialTheme(): ThemeMode {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
export function AppShell({
|
||||
children,
|
||||
animationSources,
|
||||
latestAgenda,
|
||||
latestPost,
|
||||
}: AppShellProps) {
|
||||
const locale = useLocale();
|
||||
const announcement = ANNOUNCEMENT_ITEMS[0];
|
||||
const [theme, setTheme] = useState<ThemeMode>(getInitialTheme);
|
||||
const isStandaloneLinksPage =
|
||||
pathname === "/links" || pathname.startsWith("/links/");
|
||||
const isStandaloneMediaKitPage =
|
||||
pathname === "/media-kit" || pathname.startsWith("/media-kit/");
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.poyrazTheme = theme;
|
||||
@@ -46,28 +54,37 @@ export function AppShell({ children }: AppShellProps) {
|
||||
localStorage.setItem("poyraz-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
if (isStandaloneLinksPage || isStandaloneMediaKitPage) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const localizedText = announcement ? getLocalizedValue(announcement.text, locale) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<AtaturkWidgetModal theme={theme} />
|
||||
{ENABLE_NEKO_FOLLOWER ? <NekoFollower /> : null}
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col px-4 py-4 ">
|
||||
<SiteNavbar theme={theme} onThemeChange={setTheme} />
|
||||
{announcement ? (
|
||||
<AnnouncementBar
|
||||
variant="branded"
|
||||
dismissible={false}
|
||||
icon={<Icon icon="mdi:sparkles" width={16} height={16} />}
|
||||
>
|
||||
{localizedText}
|
||||
</AnnouncementBar>
|
||||
) : null}
|
||||
<main className="flex-1 py-4">{children}</main>
|
||||
<div className="mx-auto grid w-full max-w-[1800px] grid-cols-1 gap-4 px-4 min-[1420px]:grid-cols-[220px_minmax(0,896px)_220px] min-[1420px]:justify-between">
|
||||
<LayoutLeftPromoRail
|
||||
latestAgenda={latestAgenda}
|
||||
latestPost={latestPost}
|
||||
/>
|
||||
<div className="min-w-0 w-full max-w-4xl justify-self-center min-[1420px]:max-w-none">
|
||||
<div className="pt-4">
|
||||
<SiteNavbar
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
animationSources={animationSources}
|
||||
/>
|
||||
{announcement ? (
|
||||
<AnnouncementBar
|
||||
variant="branded"
|
||||
dismissible={false}
|
||||
icon={<Icon icon="mdi:sparkles" width={16} height={16} />}
|
||||
>
|
||||
{localizedText}
|
||||
</AnnouncementBar>
|
||||
) : null}
|
||||
</div>
|
||||
<main className="min-w-0 py-4">{children}</main>
|
||||
</div>
|
||||
<LayoutRightPromoRail />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Typography } from "poyraz-ui/atoms";
|
||||
import { parseMarkdownHeadings } from "@/lib/markdown-headings";
|
||||
|
||||
type ArticleTocProps = {
|
||||
markdown: string;
|
||||
title: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
export function ArticleToc({ markdown, title, onNavigate }: ArticleTocProps) {
|
||||
const headings = useMemo(() => parseMarkdownHeadings(markdown), [markdown]);
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const rafRef = useRef(0);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(id: string) => {
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
setActiveId(id);
|
||||
onNavigate?.();
|
||||
},
|
||||
[onNavigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return;
|
||||
|
||||
const headingElements = headings
|
||||
.map((heading) => ({
|
||||
id: heading.id,
|
||||
element: document.getElementById(heading.id),
|
||||
}))
|
||||
.filter((item): item is { id: string; element: HTMLElement } =>
|
||||
Boolean(item.element),
|
||||
);
|
||||
|
||||
if (headingElements.length === 0) return;
|
||||
|
||||
const updateActive = () => {
|
||||
let current = headingElements[0].id;
|
||||
|
||||
for (const item of headingElements) {
|
||||
if (item.element.getBoundingClientRect().top <= 120) {
|
||||
current = item.id;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setActiveId(current);
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
() => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(updateActive);
|
||||
},
|
||||
{
|
||||
rootMargin: "-120px 0px -65% 0px",
|
||||
threshold: [0, 1],
|
||||
},
|
||||
);
|
||||
|
||||
for (const item of headingElements) {
|
||||
observer.observe(item.element);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(updateActive, 200);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [headings]);
|
||||
|
||||
if (headings.length < 2) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={title}
|
||||
className="max-h-[calc(100vh-7rem)] space-y-1 overflow-y-auto overscroll-contain pr-1 [scrollbar-width:thin]"
|
||||
>
|
||||
<Typography variant="small" className="mb-2 font-semibold text-foreground">
|
||||
{title}
|
||||
</Typography>
|
||||
{headings.map((heading, index) => (
|
||||
<button
|
||||
key={`${heading.id}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleClick(heading.id)}
|
||||
className={[
|
||||
"block w-full cursor-pointer truncate border-l-2 text-left text-xs leading-relaxed transition-colors",
|
||||
heading.level === 3 ? "pl-5" : "pl-3",
|
||||
activeId === heading.id
|
||||
? "border-red-600 text-red-600"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
{heading.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function AtaturkWidgetModal({ theme }: AtaturkWidgetModalProps) {
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src="/ataturk.png"
|
||||
src="/ataturk.webp"
|
||||
alt={t("alt")}
|
||||
fill
|
||||
sizes="56px"
|
||||
|
||||
+67
-44
@@ -19,9 +19,13 @@ import type { BlogPageData } from "@/data/blog";
|
||||
|
||||
type BlogContentProps = {
|
||||
data: BlogPageData;
|
||||
section?: "blog" | "agenda";
|
||||
};
|
||||
|
||||
function buildHref(params: { page?: number; category?: string; search?: string }) {
|
||||
function buildHref(
|
||||
params: { page?: number; category?: string; search?: string },
|
||||
section: "blog" | "agenda",
|
||||
) {
|
||||
const qs = new URLSearchParams();
|
||||
|
||||
if (params.page && params.page > 1) qs.set("page", String(params.page));
|
||||
@@ -29,12 +33,15 @@ function buildHref(params: { page?: number; category?: string; search?: string }
|
||||
if (params.search) qs.set("search", params.search);
|
||||
|
||||
const query = qs.toString();
|
||||
return query ? `/blog?${query}` : "/blog";
|
||||
const basePath = section === "agenda" ? "/agenda" : "/blog";
|
||||
return query ? `${basePath}?${query}` : basePath;
|
||||
}
|
||||
|
||||
export function BlogContent({ data }: BlogContentProps) {
|
||||
export function BlogContent({ data, section = "blog" }: BlogContentProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("Blog");
|
||||
const agendaT = useTranslations("Agenda");
|
||||
const isAgenda = section === "agenda";
|
||||
const [searchInput, setSearchInput] = useState(data.searchQuery);
|
||||
const pageNumbers = Array.from({ length: data.totalPages }, (_, i) => i + 1);
|
||||
const hasArticles = data.articles.length > 0;
|
||||
@@ -42,15 +49,15 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
const submitSearch = useCallback(
|
||||
(value: string) => {
|
||||
const trimmed = value.trim();
|
||||
router.push(buildHref({ category: data.selectedCategory, search: trimmed }));
|
||||
router.push(buildHref({ category: data.selectedCategory, search: trimmed }, section));
|
||||
},
|
||||
[router, data.selectedCategory],
|
||||
[router, data.selectedCategory, section],
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchInput("");
|
||||
router.push("/blog");
|
||||
}, [router]);
|
||||
router.push(section === "agenda" ? "/agenda" : "/blog");
|
||||
}, [router, section]);
|
||||
|
||||
return (
|
||||
<section className="flex h-full flex-col gap-4 overflow-y-auto">
|
||||
@@ -58,24 +65,26 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
<Card className="rounded-sm border-border p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Kategoriler */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography variant="small" className="mr-1 text-muted-foreground">
|
||||
{t("categories")}:
|
||||
</Typography>
|
||||
{data.categories.map((category) => (
|
||||
<Link
|
||||
key={category}
|
||||
href={buildHref({ category, search: data.searchQuery })}
|
||||
>
|
||||
<Badge
|
||||
variant={category === data.selectedCategory ? "default" : "outline"}
|
||||
className="cursor-pointer rounded-sm transition-colors"
|
||||
{!isAgenda && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography variant="small" className="mr-1 text-muted-foreground">
|
||||
{t("categories")}:
|
||||
</Typography>
|
||||
{data.categories.map((category) => (
|
||||
<Link
|
||||
key={category}
|
||||
href={buildHref({ category, search: data.searchQuery }, section)}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Badge
|
||||
variant={category === data.selectedCategory ? "default" : "outline"}
|
||||
className="cursor-pointer rounded-sm transition-colors"
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arama */}
|
||||
<div className="relative">
|
||||
@@ -86,14 +95,15 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
id="blog-search"
|
||||
id={isAgenda ? "agenda-search" : "blog-search"}
|
||||
aria-label={isAgenda ? agendaT("searchPlaceholder") : t("searchPlaceholder")}
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitSearch(searchInput);
|
||||
}}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
placeholder={isAgenda ? agendaT("searchPlaceholder") : t("searchPlaceholder")}
|
||||
className="w-full rounded-sm border border-border bg-background py-2 pr-10 pl-9 text-sm text-foreground placeholder:text-muted-foreground focus:border-red-600 focus:ring-1 focus:ring-red-600/30 focus:outline-none"
|
||||
/>
|
||||
{searchInput && (
|
||||
@@ -146,7 +156,7 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
readTime={post.readTime}
|
||||
href={post.href}
|
||||
className="rounded-sm border-border [&_h3]:line-clamp-2 [&_h3]:min-h-[2.5rem] [&_p]:line-clamp-3"
|
||||
author={{ name: post.author, avatar: "/logo/logo.png" }}
|
||||
author={{ name: post.author, avatar: "/logo/logo-96.webp" }}
|
||||
/>
|
||||
</StaggerItem>
|
||||
))}
|
||||
@@ -161,9 +171,13 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
/>
|
||||
<Typography variant="p" className="text-muted-foreground">
|
||||
{data.searchQuery
|
||||
? t("noSearch", { search: data.searchQuery })
|
||||
? isAgenda
|
||||
? agendaT("noSearch", { search: data.searchQuery })
|
||||
: t("noSearch", { search: data.searchQuery })
|
||||
: data.selectedCategory === "All"
|
||||
? t("empty")
|
||||
? isAgenda
|
||||
? agendaT("empty")
|
||||
: t("empty")
|
||||
: t("emptyCategory", { category: data.selectedCategory })}
|
||||
</Typography>
|
||||
{(data.searchQuery || data.selectedCategory !== "All") && (
|
||||
@@ -185,11 +199,14 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
<PaginationContent className="justify-start">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={buildHref({
|
||||
page: Math.max(1, data.currentPage - 1),
|
||||
category: data.selectedCategory,
|
||||
search: data.searchQuery,
|
||||
})}
|
||||
href={buildHref(
|
||||
{
|
||||
page: Math.max(1, data.currentPage - 1),
|
||||
category: data.selectedCategory,
|
||||
search: data.searchQuery,
|
||||
},
|
||||
section,
|
||||
)}
|
||||
aria-disabled={data.currentPage <= 1}
|
||||
/>
|
||||
</PaginationItem>
|
||||
@@ -197,11 +214,14 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
{pageNumbers.map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href={buildHref({
|
||||
page,
|
||||
category: data.selectedCategory,
|
||||
search: data.searchQuery,
|
||||
})}
|
||||
href={buildHref(
|
||||
{
|
||||
page,
|
||||
category: data.selectedCategory,
|
||||
search: data.searchQuery,
|
||||
},
|
||||
section,
|
||||
)}
|
||||
isActive={page === data.currentPage}
|
||||
>
|
||||
{page}
|
||||
@@ -211,11 +231,14 @@ export function BlogContent({ data }: BlogContentProps) {
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={buildHref({
|
||||
page: Math.min(data.totalPages, data.currentPage + 1),
|
||||
category: data.selectedCategory,
|
||||
search: data.searchQuery,
|
||||
})}
|
||||
href={buildHref(
|
||||
{
|
||||
page: Math.min(data.totalPages, data.currentPage + 1),
|
||||
category: data.selectedCategory,
|
||||
search: data.searchQuery,
|
||||
},
|
||||
section,
|
||||
)}
|
||||
aria-disabled={data.currentPage >= data.totalPages}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
@@ -7,7 +7,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Badge, Card, Typography } from "poyraz-ui/atoms";
|
||||
import type { BlogDetail } from "@/data/blog-detail";
|
||||
@@ -16,6 +16,7 @@ import { GiscusComments } from "@/components/giscus-comments";
|
||||
|
||||
type BlogDetailContentProps = {
|
||||
post: BlogDetail;
|
||||
section?: "blog" | "agenda";
|
||||
};
|
||||
|
||||
function isHttpUrl(value: string) {
|
||||
@@ -44,15 +45,13 @@ function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
<Image
|
||||
src={src}
|
||||
alt={caption}
|
||||
width={1200}
|
||||
height={675}
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 900px"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
@@ -98,7 +97,8 @@ function MermaidBlock({ chart }: { chart: string }) {
|
||||
const t = useTranslations("Blog");
|
||||
const [svg, setSvg] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);
|
||||
const generatedId = useId();
|
||||
const idRef = useRef(`mermaid-${generatedId.replace(/:/g, "")}`);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -163,8 +163,9 @@ const GISCUS_CATEGORY =
|
||||
process.env.NEXT_PUBLIC_GISCUS_CATEGORY || "Announcements";
|
||||
const GISCUS_CATEGORY_ID = process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID || "";
|
||||
|
||||
export function BlogDetailContent({ post }: BlogDetailContentProps) {
|
||||
export function BlogDetailContent({ post, section = "blog" }: BlogDetailContentProps) {
|
||||
const t = useTranslations("Blog");
|
||||
const agendaT = useTranslations("Agenda");
|
||||
const progressBarRef = useRef<HTMLDivElement | null>(null);
|
||||
const [tocOpen, setTocOpen] = useState(false);
|
||||
|
||||
@@ -199,10 +200,10 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<article className="space-y-5 rounded-sm border border-border p-5 md:p-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
href={section === "agenda" ? "/agenda" : "/blog"}
|
||||
className="inline-flex items-center rounded-sm border border-border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t("backToBlog")}
|
||||
{section === "agenda" ? agendaT("backToAgenda") : t("backToBlog")}
|
||||
</Link>
|
||||
|
||||
<header className="space-y-3">
|
||||
|
||||
+140
-144
@@ -1,107 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Button, ButtonIcon, ButtonLabel, Card, Typography } from "poyraz-ui/atoms";
|
||||
import { Modal, ModalContent, ModalTitle } from "poyraz-ui/molecules";
|
||||
import { YoutubeLiteEmbed } from "@/components/youtube-lite-embed";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { YoutubeLiteEmbed } from "@/components/youtube-lite-embed";
|
||||
import type { XVideo } from "@/data/x-videos";
|
||||
import { X_JAVASCRIPT_ANATOMY_URL } from "@/data/x-videos";
|
||||
import type { PdfNote } from "@/lib/content-page";
|
||||
|
||||
type ContentContentProps = {
|
||||
youtubeLinks: readonly string[];
|
||||
pdfFiles: string[];
|
||||
pdfFiles: PdfNote[];
|
||||
xVideos: readonly XVideo[];
|
||||
};
|
||||
|
||||
function PdfFirstPagePreview({ src, title }: { src: string; title: string }) {
|
||||
const t = useTranslations("Content");
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadingTask: {
|
||||
promise: Promise<unknown>;
|
||||
destroy?: () => void;
|
||||
} | null = null;
|
||||
|
||||
const render = async () => {
|
||||
try {
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
const lib = pdfjs as unknown as {
|
||||
version: string;
|
||||
getDocument: (src: string) => {
|
||||
promise: Promise<unknown>;
|
||||
destroy?: () => void;
|
||||
};
|
||||
GlobalWorkerOptions: { workerSrc: string };
|
||||
};
|
||||
|
||||
lib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${lib.version}/build/pdf.worker.min.mjs`;
|
||||
loadingTask = lib.getDocument(src);
|
||||
|
||||
const pdf = (await loadingTask.promise) as {
|
||||
getPage: (page: number) => Promise<{
|
||||
getViewport: (opts: { scale: number }) => {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
render: (opts: {
|
||||
canvasContext: CanvasRenderingContext2D;
|
||||
viewport: { width: number; height: number };
|
||||
}) => { promise: Promise<void> };
|
||||
}>;
|
||||
};
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || cancelled) return;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(viewport.width * ratio);
|
||||
canvas.height = Math.floor(viewport.height * ratio);
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "auto";
|
||||
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (loadingTask?.destroy) {
|
||||
loadingTask.destroy();
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted/20 p-2">
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
{t("pdfPreviewError")}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type SectionHeadingProps = {
|
||||
title: string;
|
||||
titlePrefix?: string;
|
||||
titleIcon: string;
|
||||
titleIconClassName?: string;
|
||||
href: string;
|
||||
label: string;
|
||||
handle: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
function SectionHeading({
|
||||
title,
|
||||
titlePrefix,
|
||||
titleIcon,
|
||||
titleIconClassName,
|
||||
href,
|
||||
label,
|
||||
handle,
|
||||
icon,
|
||||
}: SectionHeadingProps) {
|
||||
return (
|
||||
<div className="w-full bg-white p-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-label={title}
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Typography
|
||||
variant="large"
|
||||
className="inline-flex items-center gap-1.5 text-base"
|
||||
>
|
||||
{titlePrefix ? <span>{titlePrefix}</span> : null}
|
||||
<Icon
|
||||
icon={titleIcon}
|
||||
width={18}
|
||||
height={18}
|
||||
aria-hidden="true"
|
||||
className={titleIconClassName}
|
||||
/>
|
||||
<span>{title}</span>
|
||||
</Typography>
|
||||
<Button asChild variant="outline" effect="swap" size="xs" radius="sm">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
>
|
||||
<ButtonIcon>
|
||||
<Icon icon={icon} width={15} height={15} />
|
||||
</ButtonIcon>
|
||||
<ButtonLabel>{handle}</ButtonLabel>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +74,7 @@ function PdfFirstPagePreview({ src, title }: { src: string; title: string }) {
|
||||
export function ContentContent({
|
||||
youtubeLinks,
|
||||
pdfFiles,
|
||||
xVideos,
|
||||
}: ContentContentProps) {
|
||||
const t = useTranslations("Content");
|
||||
const [pdfModalOpen, setPdfModalOpen] = useState(false);
|
||||
@@ -117,7 +83,6 @@ export function ContentContent({
|
||||
const activePdf = pdfFiles[activePdfIndex] ?? null;
|
||||
const canGoPrev = activePdfIndex > 0;
|
||||
const canGoNext = activePdfIndex < pdfFiles.length - 1;
|
||||
|
||||
const embeddedVideos = useMemo(
|
||||
() => youtubeLinks.slice(0, 3),
|
||||
[youtubeLinks],
|
||||
@@ -129,25 +94,19 @@ export function ContentContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex h-full flex-col gap-3 overflow-y-auto">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Typography variant="large" className="text-base">
|
||||
{t("youtubeTitle")}
|
||||
</Typography>
|
||||
<Button asChild variant="outline" effect="swap" size="xs" radius="sm">
|
||||
<a
|
||||
href="https://youtube.com/@poyrazavsever"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("youtubeChannel")}
|
||||
>
|
||||
<ButtonIcon>
|
||||
<Icon icon="mdi:youtube" width={15} height={15} />
|
||||
</ButtonIcon>
|
||||
<ButtonLabel>@poyrazavsever</ButtonLabel>
|
||||
</a>
|
||||
</Button>
|
||||
<section className="flex h-full flex-col gap-4 overflow-y-auto">
|
||||
<section className="space-y-2" aria-labelledby="youtube-section-title">
|
||||
<div id="youtube-section-title">
|
||||
<SectionHeading
|
||||
title={t("youtubeTitle")}
|
||||
titlePrefix={t("youtubeTitlePrefix")}
|
||||
titleIcon="mdi:youtube"
|
||||
titleIconClassName="text-red-600"
|
||||
href="https://youtube.com/@poyrazavsever"
|
||||
label={t("youtubeChannel")}
|
||||
handle="@poyrazavsever"
|
||||
icon="mdi:youtube"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{embeddedVideos.map((link) => (
|
||||
@@ -161,50 +120,86 @@ export function ContentContent({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Typography variant="large" className="text-base">
|
||||
{t("pdfTitle")}
|
||||
</Typography>
|
||||
<Button asChild variant="outline" effect="swap" size="xs" radius="sm">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/poyrazavsever/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("linkedinProfile")}
|
||||
>
|
||||
<ButtonIcon>
|
||||
<Icon icon="mdi:linkedin" width={15} height={15} />
|
||||
</ButtonIcon>
|
||||
<ButtonLabel>@poyrazavsever</ButtonLabel>
|
||||
</a>
|
||||
</Button>
|
||||
<section className="space-y-2" aria-labelledby="linkedin-section-title">
|
||||
<div id="linkedin-section-title">
|
||||
<SectionHeading
|
||||
title={t("pdfTitle")}
|
||||
titleIcon="mdi:linkedin"
|
||||
titleIconClassName="text-[#0a66c2]"
|
||||
href="https://www.linkedin.com/in/poyrazavsever/"
|
||||
label={t("linkedinProfile")}
|
||||
handle="@poyrazavsever"
|
||||
icon="mdi:linkedin"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3">
|
||||
{pdfFiles.map((pdf, index) => (
|
||||
<button
|
||||
key={pdf}
|
||||
key={pdf.fileName}
|
||||
type="button"
|
||||
onClick={() => openPdfModal(index)}
|
||||
className="cursor-pointer text-left"
|
||||
className="cursor-pointer text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-label={t("openPdf", { name: pdf.title })}
|
||||
>
|
||||
<Card className="overflow-hidden rounded-sm border-border p-0 transition-colors hover:border-zinc-700">
|
||||
<PdfFirstPagePreview
|
||||
src={`/pdf/${pdf}`}
|
||||
title={t("pdfPreviewTitle", { name: pdf })}
|
||||
/>
|
||||
<Card className="relative aspect-4/5 overflow-hidden rounded-sm border-border bg-muted/20 p-0 transition-colors hover:border-zinc-700">
|
||||
{pdf.thumbnailSrc ? (
|
||||
<Image
|
||||
src={pdf.thumbnailSrc}
|
||||
alt={t("pdfPreviewTitle", { name: pdf.title })}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center text-muted-foreground">
|
||||
<Icon icon="mdi:file-pdf-box" width={34} height={34} />
|
||||
<Typography variant="small">{pdf.title}</Typography>
|
||||
</span>
|
||||
)}
|
||||
</Card>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2" aria-labelledby="x-section-title">
|
||||
<div id="x-section-title">
|
||||
<SectionHeading
|
||||
title={t("xTitle")}
|
||||
titleIcon="ri:twitter-x-fill"
|
||||
href={X_JAVASCRIPT_ANATOMY_URL}
|
||||
label={t("xSeries")}
|
||||
handle="@poyrazavsever"
|
||||
icon="ri:twitter-x-fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{xVideos.map((video) => (
|
||||
<Card
|
||||
key={video.src}
|
||||
className="relative aspect-video overflow-hidden rounded-sm border-border bg-black p-0"
|
||||
>
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
aria-label={t("xVideoTitle", { episode: video.episode })}
|
||||
>
|
||||
<source src={video.src} type="video/mp4" />
|
||||
{t("videoUnsupported")}
|
||||
</video>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal open={pdfModalOpen} onOpenChange={setPdfModalOpen}>
|
||||
<ModalContent size="xl" className="rounded-sm p-4">
|
||||
<ModalTitle>
|
||||
{activePdf ? activePdf.replace(/\.pdf$/i, "") : t("pdfModalDefaultTitle")}
|
||||
{activePdf?.title ?? t("pdfModalDefaultTitle")}
|
||||
</ModalTitle>
|
||||
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
{pdfFiles.length === 0
|
||||
@@ -218,7 +213,7 @@ export function ContentContent({
|
||||
className="rounded-sm"
|
||||
disabled={!canGoPrev}
|
||||
onClick={() =>
|
||||
setActivePdfIndex((prev) => Math.max(0, prev - 1))
|
||||
setActivePdfIndex((previous) => Math.max(0, previous - 1))
|
||||
}
|
||||
>
|
||||
{t("prev")}
|
||||
@@ -229,8 +224,8 @@ export function ContentContent({
|
||||
className="rounded-sm"
|
||||
disabled={!canGoNext}
|
||||
onClick={() =>
|
||||
setActivePdfIndex((prev) =>
|
||||
Math.min(pdfFiles.length - 1, prev + 1),
|
||||
setActivePdfIndex((previous) =>
|
||||
Math.min(pdfFiles.length - 1, previous + 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -238,13 +233,14 @@ export function ContentContent({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{activePdf ? (
|
||||
<div className="mt-3 h-[70dvh] overflow-hidden rounded-sm border border-border">
|
||||
<iframe
|
||||
src={`/pdf/${activePdf}`}
|
||||
title={activePdf}
|
||||
src={activePdf.href}
|
||||
title={activePdf.title}
|
||||
className="h-full w-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { GithubContributionDay } from "@/lib/project-feeds";
|
||||
|
||||
const CELL_SIZE = 10;
|
||||
const CELL_GAP = 2;
|
||||
const CELL_STEP = CELL_SIZE + CELL_GAP;
|
||||
const GRID_LEFT = 27;
|
||||
const GRID_TOP = 20;
|
||||
const GRAPH_HEIGHT = 104;
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const LEVEL_CLASSES: Record<GithubContributionDay["level"], string> = {
|
||||
0: "github-contribution-empty",
|
||||
1: "fill-[#ff7373]",
|
||||
2: "fill-[#ff5959]",
|
||||
3: "fill-[#dc2626]",
|
||||
4: "fill-[#b01e1e]",
|
||||
};
|
||||
|
||||
type ContributionGraphLabels = {
|
||||
calendar: string;
|
||||
unavailable: string;
|
||||
none: string;
|
||||
singular: string;
|
||||
plural: string;
|
||||
};
|
||||
|
||||
type PositionedDay = GithubContributionDay & {
|
||||
dayIndex: number;
|
||||
weekIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type ActiveDay = PositionedDay & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
function parseDate(date: string) {
|
||||
return new Date(`${date}T00:00:00Z`);
|
||||
}
|
||||
|
||||
export function GithubContributionGraph({
|
||||
days,
|
||||
labels,
|
||||
locale,
|
||||
}: {
|
||||
days: GithubContributionDay[];
|
||||
labels: ContributionGraphLabels;
|
||||
locale: string;
|
||||
}) {
|
||||
const [activeDay, setActiveDay] = useState<ActiveDay | null>(null);
|
||||
|
||||
const graph = useMemo(() => {
|
||||
if (days.length === 0) return null;
|
||||
|
||||
const firstDate = parseDate(days[0].date);
|
||||
const positionedDays: PositionedDay[] = days.map((day) => {
|
||||
const date = parseDate(day.date);
|
||||
const weekIndex = Math.floor(
|
||||
(date.getTime() - firstDate.getTime()) / (7 * DAY_IN_MS),
|
||||
);
|
||||
const dayIndex = date.getUTCDay();
|
||||
|
||||
return {
|
||||
...day,
|
||||
dayIndex,
|
||||
weekIndex,
|
||||
x: GRID_LEFT + weekIndex * CELL_STEP,
|
||||
y: GRID_TOP + dayIndex * CELL_STEP,
|
||||
};
|
||||
});
|
||||
const weekCount =
|
||||
Math.max(...positionedDays.map((day) => day.weekIndex)) + 1;
|
||||
const width = GRID_LEFT + weekCount * CELL_STEP + CELL_STEP;
|
||||
const monthFormatter = new Intl.DateTimeFormat(
|
||||
locale === "tr" ? "tr-TR" : "en-US",
|
||||
{ month: "short", timeZone: "UTC" },
|
||||
);
|
||||
const dateFormatter = new Intl.DateTimeFormat(
|
||||
locale === "tr" ? "tr-TR" : "en-US",
|
||||
{ day: "numeric", month: "long", year: "numeric", timeZone: "UTC" },
|
||||
);
|
||||
const months: Array<{ label: string; x: number }> = [];
|
||||
let previousMonth = firstDate.getUTCMonth();
|
||||
|
||||
positionedDays.forEach((day, index) => {
|
||||
const date = parseDate(day.date);
|
||||
const month = date.getUTCMonth();
|
||||
|
||||
if (index > 0 && month !== previousMonth) {
|
||||
months.push({
|
||||
label: monthFormatter.format(date).replace(".", ""),
|
||||
x: day.x,
|
||||
});
|
||||
}
|
||||
|
||||
previousMonth = month;
|
||||
});
|
||||
|
||||
return { positionedDays, months, width, dateFormatter };
|
||||
}, [days, locale]);
|
||||
|
||||
if (!graph) {
|
||||
return (
|
||||
<div className="flex min-h-28 items-center justify-center text-sm text-muted-foreground">
|
||||
{labels.unavailable}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const weekdayLabels =
|
||||
locale === "tr"
|
||||
? [
|
||||
{ index: 1, label: "Pzt" },
|
||||
{ index: 3, label: "Çar" },
|
||||
{ index: 5, label: "Cum" },
|
||||
]
|
||||
: [
|
||||
{ index: 1, label: "Mon" },
|
||||
{ index: 3, label: "Wed" },
|
||||
{ index: 5, label: "Fri" },
|
||||
];
|
||||
|
||||
function getTooltipLabel(day: GithubContributionDay) {
|
||||
const date = graph?.dateFormatter.format(parseDate(day.date)) ?? day.date;
|
||||
|
||||
if (day.count === 0) return `${date}: ${labels.none}`;
|
||||
|
||||
return `${date}: ${day.count} ${
|
||||
day.count === 1 ? labels.singular : labels.plural
|
||||
}`;
|
||||
}
|
||||
|
||||
const tooltipX = activeDay
|
||||
? Math.max(100, Math.min(graph.width - 100, activeDay.x + CELL_SIZE / 2))
|
||||
: 0;
|
||||
const tooltipBelow = Boolean(activeDay && activeDay.y < 48);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-sm">
|
||||
<div
|
||||
className="relative min-w-[740px]"
|
||||
style={{ aspectRatio: `${graph.width} / ${GRAPH_HEIGHT}` }}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${graph.width} ${GRAPH_HEIGHT}`}
|
||||
aria-label={labels.calendar}
|
||||
role="grid"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
onMouseLeave={() => setActiveDay(null)}
|
||||
>
|
||||
{weekdayLabels.map((day) => (
|
||||
<text
|
||||
key={day.index}
|
||||
x="0"
|
||||
y={GRID_TOP + day.index * CELL_STEP + 8}
|
||||
className="fill-muted-foreground text-[9px]"
|
||||
>
|
||||
{day.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{graph.months.map((month) => (
|
||||
<text
|
||||
key={`${month.label}-${month.x}`}
|
||||
x={month.x}
|
||||
y="10"
|
||||
className="fill-muted-foreground text-[10px]"
|
||||
>
|
||||
{month.label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{Array.from({ length: 7 }, (_, dayIndex) => (
|
||||
<g key={dayIndex} role="row">
|
||||
{graph.positionedDays
|
||||
.filter((day) => day.dayIndex === dayIndex)
|
||||
.map((day) => {
|
||||
const label = getTooltipLabel(day);
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={day.date}
|
||||
x={day.x}
|
||||
y={day.y}
|
||||
width={CELL_SIZE}
|
||||
height={CELL_SIZE}
|
||||
rx="1"
|
||||
role="gridcell"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
className={`${LEVEL_CLASSES[day.level]} cursor-default outline-none transition-opacity hover:opacity-75 focus-visible:stroke-foreground focus-visible:stroke-2`}
|
||||
onMouseEnter={() =>
|
||||
setActiveDay({ ...day, label })
|
||||
}
|
||||
onFocus={() => setActiveDay({ ...day, label })}
|
||||
onBlur={() => setActiveDay(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{activeDay ? (
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute z-10 max-w-56 -translate-x-1/2 rounded-sm border border-border bg-popover px-2 py-1 text-center text-xs text-popover-foreground shadow-md"
|
||||
style={{
|
||||
left: `${(tooltipX / graph.width) * 100}%`,
|
||||
top: `${
|
||||
((activeDay.y + (tooltipBelow ? CELL_SIZE + 3 : -3)) /
|
||||
GRAPH_HEIGHT) *
|
||||
100
|
||||
}%`,
|
||||
transform: tooltipBelow
|
||||
? "translateX(-50%)"
|
||||
: "translate(-50%, -100%)",
|
||||
}}
|
||||
>
|
||||
{activeDay.label}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import Script from "next/script";
|
||||
|
||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
||||
const DEFAULT_GA_ID = "G-TJBKHZLR7J";
|
||||
const GA_ID = process.env.NEXT_PUBLIC_GA_ID?.trim() || DEFAULT_GA_ID;
|
||||
|
||||
export function GoogleAnalytics() {
|
||||
if (!GA_ID) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
@@ -10,10 +11,11 @@ import {
|
||||
TextEffect,
|
||||
Typography,
|
||||
} from "poyraz-ui/atoms";
|
||||
import { NewsCard } from "poyraz-ui/molecules";
|
||||
import { getResumeHref } from "@/lib/links";
|
||||
import { HomeNewsCard } from "@/components/home-news-card";
|
||||
|
||||
type HomeHeroProps = {
|
||||
children?: ReactNode;
|
||||
news: {
|
||||
id: string;
|
||||
category: string;
|
||||
@@ -24,7 +26,7 @@ type HomeHeroProps = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export function HomeHero({ news }: HomeHeroProps) {
|
||||
export function HomeHero({ children, news }: HomeHeroProps) {
|
||||
const t = useTranslations("Home");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -89,6 +91,8 @@ export function HomeHero({ news }: HomeHeroProps) {
|
||||
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<section className="space-y-3 pt-12 md:pt-14">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Typography
|
||||
@@ -116,15 +120,15 @@ export function HomeHero({ news }: HomeHeroProps) {
|
||||
|
||||
<div className="relative overflow-hidden py-1">
|
||||
<div className="flex w-max items-stretch gap-3">
|
||||
{news.map((item) => (
|
||||
<NewsCard
|
||||
{news.map((item, index) => (
|
||||
<HomeNewsCard
|
||||
key={item.id}
|
||||
className="w-72 shrink-0 rounded-sm border-border"
|
||||
category={item.category}
|
||||
title={item.title}
|
||||
date={item.date}
|
||||
image={item.image}
|
||||
href={item.href}
|
||||
priority={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Image from "next/image";
|
||||
import { Badge, Card } from "poyraz-ui/atoms";
|
||||
import { Link } from "@/i18n/routing";
|
||||
|
||||
type HomeNewsCardProps = {
|
||||
category: string;
|
||||
title: string;
|
||||
date: string;
|
||||
image: string;
|
||||
href: string;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
export function HomeNewsCard({
|
||||
category,
|
||||
title,
|
||||
date,
|
||||
image,
|
||||
href,
|
||||
priority = false,
|
||||
}: HomeNewsCardProps) {
|
||||
return (
|
||||
<Link href={href} className="block w-72 shrink-0 text-inherit no-underline">
|
||||
<Card
|
||||
variant="interactive"
|
||||
className="group h-fit self-start overflow-hidden rounded-sm border-border"
|
||||
>
|
||||
<div className="flex min-h-28">
|
||||
<div className="relative w-28 shrink-0 overflow-hidden border-r border-border">
|
||||
<Image
|
||||
src={image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="112px"
|
||||
preload={priority}
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center gap-2 p-4">
|
||||
<Badge size="sm" variant="outline">
|
||||
{category}
|
||||
</Badge>
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">{date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Button, ButtonIcon, ButtonLabel, Typography } from "poyraz-ui/atoms";
|
||||
import { ImageCard } from "poyraz-ui/molecules";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { WEB_APPS } from "@/data/projects";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
import { ProjectCardWithPopover } from "@/components/project-card-with-popover";
|
||||
|
||||
export function HomeProjectsSection() {
|
||||
const t = useTranslations("Home");
|
||||
const tProjects = useTranslations("Projects");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const projects = WEB_APPS.slice(0, 5);
|
||||
@@ -42,11 +43,11 @@ export function HomeProjectsSection() {
|
||||
|
||||
<div className="relative overflow-hidden py-1">
|
||||
<div className="flex w-max items-stretch gap-2">
|
||||
{projects.map((project) => (
|
||||
<ImageCard
|
||||
{projects.map((project, index) => (
|
||||
<ProjectCardWithPopover
|
||||
key={project.id}
|
||||
image={project.image}
|
||||
title={project.title}
|
||||
title={getLocalizedValue(project.title, locale)}
|
||||
description={getLocalizedValue(project.description, locale)}
|
||||
badge={
|
||||
project.badge
|
||||
@@ -54,7 +55,13 @@ export function HomeProjectsSection() {
|
||||
: undefined
|
||||
}
|
||||
href={project.href}
|
||||
className="aspect-square w-56 shrink-0 rounded-sm border-border md:w-[calc((100vw-8rem)/4)] md:max-w-56"
|
||||
technologies={project.technologies}
|
||||
architecture={getLocalizedValue(project.architecture, locale)}
|
||||
technologiesLabel={tProjects("technologies")}
|
||||
architectureLabel={tProjects("architecture")}
|
||||
triggerClassName="w-56 shrink-0 md:w-[calc((100vw-8rem)/4)] md:max-w-56"
|
||||
className="aspect-square rounded-sm border-border"
|
||||
priority={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Badge, Typography } from "poyraz-ui/atoms";
|
||||
import { TECHNOLOGY_STACK } from "@/data/technology-stack";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
|
||||
export function HomeTechnologyStack() {
|
||||
const t = useTranslations("Home");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-4 pb-4 pt-12 md:pt-14"
|
||||
aria-labelledby="home-technology-stack-title"
|
||||
>
|
||||
<Typography
|
||||
id="home-technology-stack-title"
|
||||
variant="h3"
|
||||
component="h2"
|
||||
className="tracking-[-0.035em]"
|
||||
>
|
||||
{t("technologiesTitle")}
|
||||
</Typography>
|
||||
|
||||
<div className="grid gap-x-8 gap-y-6 md:grid-cols-2">
|
||||
{TECHNOLOGY_STACK.map((group) => (
|
||||
<div key={group.id} className="space-y-2.5 border-t border-border pt-3">
|
||||
<Typography
|
||||
variant="small"
|
||||
component="h3"
|
||||
className="text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground"
|
||||
>
|
||||
{t(`technologyCategories.${group.id}`)}
|
||||
</Typography>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.items.map((technology) => (
|
||||
<Badge
|
||||
key={technology.id}
|
||||
variant="secondary"
|
||||
radius="sm"
|
||||
className="gap-1.5 px-2.5 py-1 text-xs font-medium text-foreground"
|
||||
>
|
||||
<Icon
|
||||
icon={technology.icon}
|
||||
width={14}
|
||||
height={14}
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span>{getLocalizedValue(technology.label, locale)}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+173
-8
@@ -1,4 +1,8 @@
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
function serializeJsonLd(data: object) {
|
||||
return JSON.stringify(data).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
export type PersonJsonLdProps = {
|
||||
name: string;
|
||||
@@ -11,7 +15,7 @@ export type PersonJsonLdProps = {
|
||||
export function PersonJsonLd({
|
||||
name,
|
||||
url = SITE_URL,
|
||||
image = `${SITE_URL}/logo/logo.png`,
|
||||
image = `${SITE_URL}/logo/logo.webp`,
|
||||
jobTitle = "Fullstack Developer",
|
||||
sameAs = [],
|
||||
}: PersonJsonLdProps) {
|
||||
@@ -28,7 +32,165 @@ export function PersonJsonLd({
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type ProfilePageJsonLdProps = PersonJsonLdProps & {
|
||||
pageUrl: string;
|
||||
pageName: string;
|
||||
description: string;
|
||||
locale: "tr" | "en";
|
||||
};
|
||||
|
||||
export function ProfilePageJsonLd({
|
||||
pageUrl,
|
||||
pageName,
|
||||
description,
|
||||
locale,
|
||||
name,
|
||||
url = SITE_URL,
|
||||
image = `${SITE_URL}/logo/logo.webp`,
|
||||
jobTitle = "Fullstack Developer",
|
||||
sameAs = [],
|
||||
}: ProfilePageJsonLdProps) {
|
||||
const data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"@id": `${pageUrl}#profile-page`,
|
||||
url: pageUrl,
|
||||
name: pageName,
|
||||
description,
|
||||
inLanguage: locale === "tr" ? "tr-TR" : "en-US",
|
||||
mainEntity: {
|
||||
"@type": "Person",
|
||||
"@id": `${SITE_URL}/#person`,
|
||||
name,
|
||||
url,
|
||||
image,
|
||||
jobTitle,
|
||||
sameAs,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type ProjectsJsonLdItem = {
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url?: string;
|
||||
technologies: string[];
|
||||
};
|
||||
|
||||
export function ProjectsJsonLd({
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
locale,
|
||||
projects,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
locale: "tr" | "en";
|
||||
projects: ProjectsJsonLdItem[];
|
||||
}) {
|
||||
const data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
"@id": `${url}#projects`,
|
||||
url,
|
||||
name,
|
||||
description,
|
||||
inLanguage: locale === "tr" ? "tr-TR" : "en-US",
|
||||
mainEntity: {
|
||||
"@type": "ItemList",
|
||||
numberOfItems: projects.length,
|
||||
itemListElement: projects.map((project, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
item: {
|
||||
"@type": "CreativeWork",
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
image: project.image.startsWith("http")
|
||||
? project.image
|
||||
: `${SITE_URL}${project.image}`,
|
||||
url: project.url,
|
||||
keywords: project.technologies,
|
||||
creator: {
|
||||
"@type": "Person",
|
||||
"@id": `${SITE_URL}/#person`,
|
||||
name: "Poyraz Avsever",
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectCaseStudyJsonLd({
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
liveUrl,
|
||||
image,
|
||||
locale,
|
||||
applicationCategory,
|
||||
technologies,
|
||||
features,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
liveUrl: string;
|
||||
image: string;
|
||||
locale: "tr" | "en";
|
||||
applicationCategory: string;
|
||||
technologies: string[];
|
||||
features: string[];
|
||||
}) {
|
||||
const data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${url}#software-application`,
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
sameAs: liveUrl,
|
||||
image: image.startsWith("http") ? image : `${SITE_URL}${image}`,
|
||||
inLanguage: locale === "tr" ? "tr-TR" : "en-US",
|
||||
applicationCategory,
|
||||
operatingSystem: "Web",
|
||||
keywords: technologies,
|
||||
featureList: features,
|
||||
author: {
|
||||
"@type": "Person",
|
||||
"@id": `${SITE_URL}/#person`,
|
||||
name: "Poyraz Avsever",
|
||||
url: SITE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +202,7 @@ export type ArticleJsonLdProps = {
|
||||
image: string;
|
||||
datePublished: string;
|
||||
authorName?: string;
|
||||
locale?: "tr" | "en";
|
||||
};
|
||||
|
||||
export function ArticleJsonLd({
|
||||
@@ -49,6 +212,7 @@ export function ArticleJsonLd({
|
||||
image,
|
||||
datePublished,
|
||||
authorName = "Poyraz Avsever",
|
||||
locale = "tr",
|
||||
}: ArticleJsonLdProps) {
|
||||
const data = {
|
||||
"@context": "https://schema.org",
|
||||
@@ -56,8 +220,11 @@ export function ArticleJsonLd({
|
||||
headline: title,
|
||||
description,
|
||||
url,
|
||||
mainEntityOfPage: url,
|
||||
image: image.startsWith("http") ? image : `${SITE_URL}${image}`,
|
||||
datePublished,
|
||||
dateModified: datePublished,
|
||||
inLanguage: locale === "tr" ? "tr-TR" : "en-US",
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: authorName,
|
||||
@@ -66,17 +233,15 @@ export function ArticleJsonLd({
|
||||
publisher: {
|
||||
"@type": "Person",
|
||||
name: authorName,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${SITE_URL}/logo/logo.png`,
|
||||
},
|
||||
url: SITE_URL,
|
||||
image: `${SITE_URL}/logo/logo.webp`,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonIcon,
|
||||
ButtonLabel,
|
||||
Card,
|
||||
Typography,
|
||||
} from "poyraz-ui/atoms";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import {
|
||||
LEFT_LAYOUT_PROMO_SLIDES,
|
||||
RIGHT_LAYOUT_PROMO_SLIDES,
|
||||
type LayoutPromoCardDefinition,
|
||||
type LayoutPromoSlide,
|
||||
} from "@/data/layout-promos";
|
||||
import { SPONSORS } from "@/data/sponsors";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
|
||||
export type LayoutContentPromo = {
|
||||
title: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type PromoRailSide = "left" | "right";
|
||||
|
||||
function RailButton({
|
||||
href,
|
||||
label,
|
||||
icon = "mdi:arrow-right",
|
||||
variant = "outline",
|
||||
external = false,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
variant?: "default" | "outline" | "secondary";
|
||||
external?: boolean;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<ButtonLabel>{label}</ButtonLabel>
|
||||
<ButtonIcon>
|
||||
<Icon icon={icon} width={14} height={14} />
|
||||
</ButtonIcon>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant={variant}
|
||||
size="xs"
|
||||
radius="sm"
|
||||
effect="swap"
|
||||
swapTarget="both"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{external ? (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href}>{content}</Link>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorLogos() {
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-2 gap-1.5">
|
||||
{SPONSORS.map((sponsor, index) => (
|
||||
<a
|
||||
key={sponsor.id}
|
||||
href={sponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={sponsor.name}
|
||||
className={`relative flex h-10 items-center justify-center rounded-sm border border-border bg-white p-1.5 transition-colors hover:border-primary/40 ${
|
||||
index === SPONSORS.length - 1 && SPONSORS.length % 2 === 1
|
||||
? "col-span-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={sponsor.logo}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100px"
|
||||
className="object-contain p-1.5 grayscale transition duration-300 hover:grayscale-0"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoIcon({
|
||||
card,
|
||||
className,
|
||||
}: {
|
||||
card: LayoutPromoCardDefinition;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Icon
|
||||
icon={card.icon}
|
||||
width={17}
|
||||
height={17}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoCard({
|
||||
card,
|
||||
latestAgenda,
|
||||
latestPost,
|
||||
}: {
|
||||
card: LayoutPromoCardDefinition;
|
||||
latestAgenda: LayoutContentPromo | null;
|
||||
latestPost: LayoutContentPromo | null;
|
||||
}) {
|
||||
const t = useTranslations("LayoutPromos");
|
||||
const locale = useLocale();
|
||||
const liveContent =
|
||||
card.contentSource === "latestAgenda"
|
||||
? latestAgenda
|
||||
: card.contentSource === "latestPost"
|
||||
? latestPost
|
||||
: null;
|
||||
const href = liveContent?.href ?? getLocalizedValue(card.href, locale);
|
||||
const description = liveContent?.title ?? t(card.descriptionKey);
|
||||
const external = card.external ?? false;
|
||||
const iconSurface = card.iconSurface ?? "accent";
|
||||
const cardClassName =
|
||||
card.surface === "primary"
|
||||
? "rounded-sm border-primary/25 bg-primary/5 p-3"
|
||||
: "rounded-sm border-border p-3";
|
||||
const iconClassName =
|
||||
iconSurface === "primary"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: iconSurface === "foreground"
|
||||
? "bg-foreground text-background"
|
||||
: "bg-accent text-foreground";
|
||||
|
||||
return (
|
||||
<Card className={cardClassName}>
|
||||
{card.eyebrowKey ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant="secondary" radius="sm">
|
||||
{t(card.eyebrowKey)}
|
||||
</Badge>
|
||||
<PromoIcon card={card} className="text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex size-8 items-center justify-center rounded-sm ${iconClassName}`}
|
||||
>
|
||||
<PromoIcon card={card} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="large"
|
||||
component="h2"
|
||||
className="mt-3 text-base leading-5 tracking-[-0.025em]"
|
||||
>
|
||||
{t(card.titleKey)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className={`mt-2 text-xs leading-5 text-muted-foreground ${
|
||||
liveContent ? "line-clamp-3" : ""
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
{card.kind === "sponsors" ? <SponsorLogos /> : null}
|
||||
|
||||
<div className="mt-3">
|
||||
<RailButton
|
||||
href={href}
|
||||
label={t(card.ctaKey)}
|
||||
icon={external ? "mdi:arrow-top-right" : "mdi:arrow-right"}
|
||||
variant={card.buttonVariant}
|
||||
external={external}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoRail({
|
||||
side,
|
||||
slides,
|
||||
latestAgenda = null,
|
||||
latestPost = null,
|
||||
}: {
|
||||
side: PromoRailSide;
|
||||
slides: readonly LayoutPromoSlide[];
|
||||
latestAgenda?: LayoutContentPromo | null;
|
||||
latestPost?: LayoutContentPromo | null;
|
||||
}) {
|
||||
const t = useTranslations("LayoutPromos");
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const direction = side === "left" ? -1 : 1;
|
||||
const activeCards = slides[activeSlide] ?? slides[0];
|
||||
const railId = `${side}-promo-rail`;
|
||||
|
||||
const selectAdjacentSlide = (step: number) => {
|
||||
setActiveSlide((current) => (current + step + slides.length) % slides.length);
|
||||
};
|
||||
|
||||
const handleNavigationKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
|
||||
event.preventDefault();
|
||||
selectAdjacentSlide(event.key === "ArrowRight" ? 1 : -1);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={t(side === "left" ? "leftRailLabel" : "rightRailLabel")}
|
||||
className="relative z-50 hidden min-[1420px]:block"
|
||||
>
|
||||
<div className="sticky top-4 py-4">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("slideNavigationLabel")}
|
||||
onKeyDown={handleNavigationKeyDown}
|
||||
className="mb-3 flex h-5 items-center justify-center gap-2"
|
||||
>
|
||||
{slides.map((_, index) => {
|
||||
const selected = index === activeSlide;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${railId}-dot-${index}`}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={t("slideCta", { slide: index + 1 })}
|
||||
onClick={() => setActiveSlide(index)}
|
||||
className="group flex size-5 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<span
|
||||
className={`block rounded-full transition-[width,background-color,transform] duration-300 group-hover:scale-110 ${
|
||||
selected
|
||||
? "h-2 w-5 bg-primary"
|
||||
: "size-2 bg-border group-hover:bg-muted-foreground/60"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{t("slideStatus", {
|
||||
current: activeSlide + 1,
|
||||
total: slides.length,
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div className="overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`${railId}-slide-${activeSlide}`}
|
||||
id={`${railId}-panel-${activeSlide}`}
|
||||
role="group"
|
||||
aria-label={t("slideStatus", {
|
||||
current: activeSlide + 1,
|
||||
total: slides.length,
|
||||
})}
|
||||
initial={
|
||||
reduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, x: -direction * 26 }
|
||||
}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={
|
||||
reduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, x: direction * 26 }
|
||||
}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0.12 : 0.38,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{activeCards.map((card, index) => (
|
||||
<motion.div
|
||||
key={card.id}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: reduceMotion ? 0 : 0.28,
|
||||
delay: reduceMotion ? 0 : index * 0.045,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
whileHover={reduceMotion ? undefined : { y: -3 }}
|
||||
>
|
||||
<PromoCard
|
||||
card={card}
|
||||
latestAgenda={latestAgenda}
|
||||
latestPost={latestPost}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutLeftPromoRail({
|
||||
latestAgenda,
|
||||
latestPost,
|
||||
}: {
|
||||
latestAgenda: LayoutContentPromo | null;
|
||||
latestPost: LayoutContentPromo | null;
|
||||
}) {
|
||||
return (
|
||||
<PromoRail
|
||||
side="left"
|
||||
slides={LEFT_LAYOUT_PROMO_SLIDES}
|
||||
latestAgenda={latestAgenda}
|
||||
latestPost={latestPost}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutRightPromoRail() {
|
||||
return <PromoRail side="right" slides={RIGHT_LAYOUT_PROMO_SLIDES} />;
|
||||
}
|
||||
+277
-229
@@ -1,18 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Typography,
|
||||
} from "poyraz-ui/atoms";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "poyraz-ui/molecules";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import {
|
||||
getResumeHref,
|
||||
LINK_DIRECTORY,
|
||||
@@ -24,6 +32,11 @@ import {
|
||||
|
||||
type CategoryFilter = "all" | LinkDirectoryCategory;
|
||||
|
||||
type LinksContentProps = {
|
||||
initialCategory?: string;
|
||||
initialQuery?: string;
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = {
|
||||
resources: 0,
|
||||
navigation: 1,
|
||||
@@ -31,12 +44,12 @@ const CATEGORY_ORDER = {
|
||||
} as const;
|
||||
|
||||
const DIRECTORY_ITEMS = [...LINK_DIRECTORY].sort((left, right) => {
|
||||
const categoryCompare = CATEGORY_ORDER[left.category] - CATEGORY_ORDER[right.category];
|
||||
if (categoryCompare !== 0) {
|
||||
return categoryCompare;
|
||||
}
|
||||
const categoryCompare =
|
||||
CATEGORY_ORDER[left.category] - CATEGORY_ORDER[right.category];
|
||||
|
||||
return left.label.localeCompare(right.label, "tr");
|
||||
return categoryCompare !== 0
|
||||
? categoryCompare
|
||||
: left.label.localeCompare(right.label, "tr");
|
||||
});
|
||||
|
||||
function normalize(value: string) {
|
||||
@@ -47,32 +60,46 @@ function normalize(value: string) {
|
||||
}
|
||||
|
||||
function formatHref(href: string) {
|
||||
if (href.startsWith("mailto:")) {
|
||||
return href.replace("mailto:", "");
|
||||
}
|
||||
|
||||
if (href.startsWith("/")) {
|
||||
return `poyrazavsever.com${href}`;
|
||||
}
|
||||
if (href.startsWith("mailto:")) return href.replace("mailto:", "");
|
||||
if (href.startsWith("/")) return `poyrazavsever.com${href}`;
|
||||
|
||||
return href.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function LinksContent() {
|
||||
function parseCategoryFilter(value?: string): CategoryFilter {
|
||||
if (value === "navigation" || value === "social" || value === "resources") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function LinkIcon({ icon, size = 18 }: { icon: string; size?: number }) {
|
||||
return <Icon icon={icon} width={size} height={size} />;
|
||||
}
|
||||
|
||||
export function LinksContent({
|
||||
initialCategory,
|
||||
initialQuery = "",
|
||||
}: LinksContentProps) {
|
||||
const t = useTranslations("Links");
|
||||
const tNav = useTranslations("Nav");
|
||||
const locale = useLocale();
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryFilter>(() =>
|
||||
parseCategoryFilter(initialCategory),
|
||||
);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filterItems = useMemo(() => [
|
||||
{ id: "all" as const, label: t("allCategories") },
|
||||
...LINK_DIRECTORY_CATEGORIES.map((item) => ({
|
||||
id: item.id,
|
||||
label: t(`categories.${item.id}`),
|
||||
})),
|
||||
], [t]);
|
||||
const filterItems = useMemo(
|
||||
() => [
|
||||
{ id: "all" as const, label: t("allCategories") },
|
||||
...LINK_DIRECTORY_CATEGORIES.map((item) => ({
|
||||
id: item.id,
|
||||
label: t(`categories.${item.id}`),
|
||||
})),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalizedQuery = normalize(query.trim());
|
||||
@@ -83,9 +110,7 @@ export function LinksContent() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (queryTokens.length === 0) return true;
|
||||
|
||||
const haystack = normalize(
|
||||
[
|
||||
@@ -101,208 +126,231 @@ export function LinksContent() {
|
||||
}, [activeCategory, query, t, tNav]);
|
||||
|
||||
return (
|
||||
<section className="relative isolate min-h-dvh overflow-hidden px-4 py-8 sm:px-6 sm:py-10">
|
||||
<section className="flex h-full flex-col gap-10">
|
||||
<header className="border-b border-border py-4 sm:py-5">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Avatar className="size-16 shrink-0 rounded-sm border border-border bg-background sm:size-20">
|
||||
<AvatarImage src="/logo/logo-96.webp" alt="Poyraz Avsever" />
|
||||
<AvatarFallback className="rounded-sm bg-muted font-semibold">
|
||||
PA
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="relative mx-auto flex w-full max-w-xl justify-center">
|
||||
<Card className="w-full overflow-hidden rounded-[28px] border-border/80 bg-background/95 shadow-[0_24px_80px_rgba(15,23,42,0.16)] backdrop-blur">
|
||||
<div className="relative aspect-1878/410 overflow-hidden border-b border-border/70">
|
||||
<img
|
||||
src="/logo/cover.png"
|
||||
alt="Poyraz Avsever kapak görseli"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-background/20 via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5 pt-0 sm:px-6 sm:pb-6">
|
||||
<div className="-mt-14 flex flex-col gap-4">
|
||||
<Avatar className="h-24 w-24 rounded-[28px] border-4 border-background bg-background shadow-lg sm:h-28 sm:w-28">
|
||||
<AvatarImage src="/logo/logo.png" alt="Poyraz Avsever" />
|
||||
<AvatarFallback className="rounded-3xl bg-muted text-lg font-semibold">
|
||||
PA
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Typography variant="h2" className="text-[1.75rem] leading-none sm:text-[2rem]">
|
||||
Poyraz
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h2"
|
||||
secondaryFont
|
||||
className="text-[1.75rem] leading-none text-red-600 sm:text-[2rem]"
|
||||
>
|
||||
Avsever
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="small" className="max-w-lg text-sm leading-6 text-muted-foreground">
|
||||
{t("desc")}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SOCIAL_LINKS.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-border bg-background text-muted-foreground transition-all hover:-translate-y-0.5 hover:border-red-600/40 hover:text-foreground"
|
||||
>
|
||||
<Icon icon={item.icon} width={18} height={18} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{TOP_ICON_LINKS.map((item) => {
|
||||
const href = item.id === "cv" ? getResumeHref(locale) : item.href;
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
href={href}
|
||||
target={item.id === "cv" || item.external ? "_blank" : undefined}
|
||||
rel={item.id === "cv" || item.external ? "noreferrer" : undefined}
|
||||
className="block"
|
||||
>
|
||||
<Card className="rounded-2xl border-border bg-muted/35 p-3 transition-all hover:-translate-y-0.5 hover:border-red-600/40 hover:bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-muted-foreground">
|
||||
<Icon icon={item.icon} width={18} height={18} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Typography variant="large" className="truncate text-base">
|
||||
{tNav.has(item.id) ? tNav(item.id) : item.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{formatHref(href)}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<div>
|
||||
<Typography variant="large" className="text-base">
|
||||
{t("allLinks")}
|
||||
</Typography>
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
{t("allLinksDesc")}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-[190px_1fr]">
|
||||
<Select
|
||||
value={activeCategory}
|
||||
onValueChange={(value) => setActiveCategory(value as CategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-11 rounded-2xl border-border bg-background px-4 text-sm">
|
||||
<SelectValue placeholder={t("selectCategory")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterItems.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("allLinks")}
|
||||
className="h-11 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{filteredItems.map((item) => {
|
||||
const href = item.id === "cv" ? getResumeHref(locale) : item.href;
|
||||
const isStaticOrExternal = item.external || item.id === "rss" || item.id === "cv" || href.endsWith(".xml") || href.endsWith(".pdf");
|
||||
const CardContent = (
|
||||
<Card className="rounded-2xl border-border p-3 transition-all hover:-translate-y-0.5 hover:border-red-600/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border bg-muted/35 text-muted-foreground">
|
||||
<Icon icon={item.icon} width={18} height={18} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography variant="large" className="text-base leading-tight">
|
||||
{tNav.has(item.id) ? tNav(item.id) : item.label}
|
||||
</Typography>
|
||||
<span className="inline-flex rounded-full border border-border px-2.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{t(`categories.${item.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{formatHref(href)}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground">
|
||||
<Icon
|
||||
icon={isStaticOrExternal ? "mdi:arrow-top-right" : "mdi:arrow-right"}
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return isStaticOrExternal ? (
|
||||
<a
|
||||
key={`${item.category}-${item.id}`}
|
||||
href={href}
|
||||
target={item.id === "cv" || item.external ? "_blank" : undefined}
|
||||
rel={item.id === "cv" || item.external ? "noreferrer" : undefined}
|
||||
className="block"
|
||||
>
|
||||
{CardContent}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={`${item.category}-${item.id}`}
|
||||
href={href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noreferrer" : undefined}
|
||||
className="block"
|
||||
>
|
||||
{CardContent}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<Card className="rounded-2xl border-border px-4 py-5">
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
{t("empty")}
|
||||
</Typography>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Typography
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="font-secondary text-2xl font-semibold leading-none tracking-[-0.045em] text-foreground"
|
||||
>
|
||||
{t("title")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mt-2 max-w-xl text-xs leading-5 text-muted-foreground sm:text-sm"
|
||||
>
|
||||
{t("desc")}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex max-w-sm flex-wrap gap-2 sm:justify-end"
|
||||
aria-label={t("socialLinks")}
|
||||
>
|
||||
{SOCIAL_LINKS.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
asChild
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
radius="sm"
|
||||
effect="shine"
|
||||
aria-label={item.label}
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.id === "email" ? undefined : "_blank"}
|
||||
rel={item.id === "email" ? undefined : "noreferrer"}
|
||||
title={item.label}
|
||||
>
|
||||
<LinkIcon icon={item.icon} size={16} />
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="space-y-3" aria-labelledby="quick-links-title">
|
||||
<div>
|
||||
<Typography
|
||||
id="quick-links-title"
|
||||
variant="h3"
|
||||
component="h2"
|
||||
className="tracking-[-0.035em]"
|
||||
>
|
||||
{t("quickLinks")}
|
||||
</Typography>
|
||||
<Typography variant="small" className="mt-1 text-muted-foreground">
|
||||
{t("quickLinksDesc")}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{TOP_ICON_LINKS.map((item) => {
|
||||
const href = item.id === "cv" ? getResumeHref(locale) : item.href;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.id}
|
||||
href={href}
|
||||
target={item.id === "cv" || item.external ? "_blank" : undefined}
|
||||
rel={item.id === "cv" || item.external ? "noreferrer" : undefined}
|
||||
className="group block"
|
||||
>
|
||||
<Card className="h-full rounded-sm border-border p-3 transition-[border-color,transform] duration-200 group-hover:-translate-y-0.5 group-hover:border-primary/40">
|
||||
<span className="inline-flex size-9 items-center justify-center rounded-sm border border-border bg-muted/35 text-muted-foreground">
|
||||
<LinkIcon icon={item.icon} />
|
||||
</span>
|
||||
<Typography variant="large" className="mt-3 text-sm leading-tight">
|
||||
{tNav.has(item.id) ? tNav(item.id) : item.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{formatHref(href)}
|
||||
</Typography>
|
||||
</Card>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3" aria-labelledby="all-links-title">
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<Typography
|
||||
id="all-links-title"
|
||||
variant="h3"
|
||||
component="h2"
|
||||
className="tracking-[-0.035em]"
|
||||
>
|
||||
{t("allLinks")}
|
||||
</Typography>
|
||||
<Typography variant="small" className="mt-1 text-muted-foreground">
|
||||
{t("allLinksDesc")}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-2 sm:grid-cols-[180px_1fr] md:max-w-lg">
|
||||
<Select
|
||||
value={activeCategory}
|
||||
onValueChange={(value) =>
|
||||
setActiveCategory(value as CategoryFilter)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-sm border-border bg-background px-3 text-sm">
|
||||
<SelectValue placeholder={t("selectCategory")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterItems.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchAriaLabel")}
|
||||
className="h-10 rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredItems.map((item) => {
|
||||
const href = item.id === "cv" ? getResumeHref(locale) : item.href;
|
||||
const isStaticOrExternal =
|
||||
item.external ||
|
||||
item.id === "rss" ||
|
||||
item.id === "cv" ||
|
||||
href.endsWith(".xml") ||
|
||||
href.endsWith(".pdf");
|
||||
const content = (
|
||||
<Card className="h-full rounded-sm border-border p-3 transition-[border-color,transform] duration-200 group-hover:-translate-y-0.5 group-hover:border-primary/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex size-10 shrink-0 items-center justify-center rounded-sm border border-border bg-muted/35 text-muted-foreground">
|
||||
<LinkIcon icon={item.icon} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography variant="large" className="text-sm leading-tight">
|
||||
{tNav.has(item.id) ? tNav(item.id) : item.label}
|
||||
</Typography>
|
||||
<Badge variant="secondary" radius="sm" className="text-[10px]">
|
||||
{t(`categories.${item.category}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{formatHref(href)}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon={
|
||||
isStaticOrExternal
|
||||
? "mdi:arrow-top-right"
|
||||
: "mdi:arrow-right"
|
||||
}
|
||||
width={17}
|
||||
height={17}
|
||||
className="shrink-0 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return isStaticOrExternal ? (
|
||||
<a
|
||||
key={`${item.category}-${item.id}`}
|
||||
href={href}
|
||||
target={item.id === "cv" || item.external ? "_blank" : undefined}
|
||||
rel={item.id === "cv" || item.external ? "noreferrer" : undefined}
|
||||
className="group block"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={`${item.category}-${item.id}`}
|
||||
href={href}
|
||||
className="group block"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<Card className="rounded-sm border-border px-4 py-5 md:col-span-2">
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
{t("empty")}
|
||||
</Typography>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+452
-524
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@ const ALERT_TIME = 3;
|
||||
const IDLE_THRESHOLD = 3;
|
||||
const IDLE_ANIMATION_CHANCE = 1 / 20;
|
||||
const MIN_DISTANCE = 10;
|
||||
const ROAM_MARGIN = 24;
|
||||
const MIN_ROAM_DISTANCE = 160;
|
||||
const MIN_IDLE_FRAMES = 8;
|
||||
const MAX_IDLE_FRAMES = 20;
|
||||
const SPRITE_GAP = 1;
|
||||
const BACKGROUND_TARGET_COLOR: [number, number, number] = [0, 174, 240];
|
||||
|
||||
@@ -21,8 +25,9 @@ type SpriteSet = Record<string, [number, number][]>;
|
||||
class Neko {
|
||||
private posX: number;
|
||||
private posY: number;
|
||||
private mouseX: number;
|
||||
private mouseY: number;
|
||||
private targetX: number;
|
||||
private targetY: number;
|
||||
private idleFramesRemaining: number;
|
||||
private frameCount: number;
|
||||
private idleTime: number;
|
||||
private idleAnimation: string | null;
|
||||
@@ -43,8 +48,9 @@ class Neko {
|
||||
this.nekoImageUrl = nekoImageUrl;
|
||||
this.posX = Math.max(NEKO_HALF_WIDTH, window.innerWidth - NEKO_HALF_WIDTH - margin);
|
||||
this.posY = Math.max(NEKO_HALF_HEIGHT, window.innerHeight - NEKO_HALF_HEIGHT - margin);
|
||||
this.mouseX = this.posX;
|
||||
this.mouseY = this.posY;
|
||||
this.targetX = this.posX;
|
||||
this.targetY = this.posY;
|
||||
this.idleFramesRemaining = this.randomIdleDuration();
|
||||
this.frameCount = 0;
|
||||
this.idleTime = 0;
|
||||
this.idleAnimation = null;
|
||||
@@ -186,18 +192,13 @@ class Neko {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private handleMouseMove = (event: MouseEvent) => {
|
||||
this.mouseX = event.clientX;
|
||||
this.mouseY = event.clientY;
|
||||
};
|
||||
|
||||
private handleResize = () => {
|
||||
this.clampToViewport();
|
||||
this.clampTargetToViewport();
|
||||
this.render();
|
||||
};
|
||||
|
||||
private addEventListeners() {
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
window.addEventListener("resize", this.handleResize);
|
||||
}
|
||||
|
||||
@@ -222,21 +223,34 @@ class Neko {
|
||||
|
||||
private updateState() {
|
||||
this.frameCount += 1;
|
||||
this.followMouse();
|
||||
}
|
||||
|
||||
private followMouse() {
|
||||
const diffX = this.posX - this.mouseX;
|
||||
const diffY = this.posY - this.mouseY;
|
||||
const distance = Math.hypot(diffX, diffY);
|
||||
|
||||
if (distance < MIN_DISTANCE) {
|
||||
if (this.idleFramesRemaining > 0 || this.idleAnimation !== null) {
|
||||
this.idleBehavior();
|
||||
|
||||
if (this.idleAnimation === null) {
|
||||
this.idleFramesRemaining -= 1;
|
||||
}
|
||||
|
||||
if (this.idleFramesRemaining <= 0 && this.idleAnimation === null) {
|
||||
this.chooseNewTarget();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.idleTime > IDLE_THRESHOLD && this.alertTimeRemaining === 0) {
|
||||
this.alertTimeRemaining = ALERT_TIME;
|
||||
this.wander();
|
||||
}
|
||||
|
||||
private wander() {
|
||||
const diffX = this.posX - this.targetX;
|
||||
const diffY = this.posY - this.targetY;
|
||||
const distance = Math.hypot(diffX, diffY);
|
||||
|
||||
if (distance < MIN_DISTANCE) {
|
||||
this.posX = this.targetX;
|
||||
this.posY = this.targetY;
|
||||
this.idleFramesRemaining = this.randomIdleDuration();
|
||||
this.idleBehavior();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.alertTimeRemaining > 0) {
|
||||
@@ -263,6 +277,37 @@ class Neko {
|
||||
this.clampToViewport();
|
||||
}
|
||||
|
||||
private chooseNewTarget() {
|
||||
const minX = Math.min(NEKO_HALF_WIDTH + ROAM_MARGIN, window.innerWidth / 2);
|
||||
const maxX = Math.max(minX, window.innerWidth - NEKO_HALF_WIDTH - ROAM_MARGIN);
|
||||
const minY = Math.min(NEKO_HALF_HEIGHT + ROAM_MARGIN, window.innerHeight / 2);
|
||||
const maxY = Math.max(minY, window.innerHeight - NEKO_HALF_HEIGHT - ROAM_MARGIN);
|
||||
|
||||
let nextX = this.posX;
|
||||
let nextY = this.posY;
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
nextX = minX + Math.random() * (maxX - minX);
|
||||
nextY = minY + Math.random() * (maxY - minY);
|
||||
|
||||
if (Math.hypot(nextX - this.posX, nextY - this.posY) >= MIN_ROAM_DISTANCE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.targetX = nextX;
|
||||
this.targetY = nextY;
|
||||
this.alertTimeRemaining = ALERT_TIME;
|
||||
this.idleTime = 0;
|
||||
}
|
||||
|
||||
private randomIdleDuration() {
|
||||
return (
|
||||
MIN_IDLE_FRAMES +
|
||||
Math.floor(Math.random() * (MAX_IDLE_FRAMES - MIN_IDLE_FRAMES + 1))
|
||||
);
|
||||
}
|
||||
|
||||
private idleBehavior() {
|
||||
this.idleTime += 1;
|
||||
|
||||
@@ -320,6 +365,17 @@ class Neko {
|
||||
);
|
||||
}
|
||||
|
||||
private clampTargetToViewport() {
|
||||
this.targetX = Math.min(
|
||||
Math.max(NEKO_HALF_WIDTH, this.targetX),
|
||||
window.innerWidth - NEKO_HALF_WIDTH,
|
||||
);
|
||||
this.targetY = Math.min(
|
||||
Math.max(NEKO_HALF_HEIGHT, this.targetY),
|
||||
window.innerHeight - NEKO_HALF_HEIGHT,
|
||||
);
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.nekoElement) return;
|
||||
this.nekoElement.style.left = `${this.posX - NEKO_HALF_WIDTH}px`;
|
||||
@@ -345,7 +401,6 @@ class Neko {
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
window.removeEventListener("resize", this.handleResize);
|
||||
|
||||
if (this.nekoElement) {
|
||||
@@ -368,4 +423,3 @@ export function NekoFollower() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
clamp,
|
||||
DEFAULT_TIME,
|
||||
mapPointerYToTime,
|
||||
SMOOTHING,
|
||||
TOTAL_DURATION,
|
||||
} from "@/lib/cursor-portrait";
|
||||
|
||||
const VIDEO_SRC = "/media/cursor-portrait/poyraz-bottom-right.mp4";
|
||||
const POSTER_SRC = "/media/cursor-portrait/poyraz-bottom-right-poster.webp";
|
||||
const NIGHT_SRC = "/media/cursor-portrait/gece.webp";
|
||||
|
||||
const SEEK_INTERVAL_MS = 1000 / 60;
|
||||
const MIN_TIME_DELTA = 0.002;
|
||||
const MIN_SEEK_DELTA = 1 / 120;
|
||||
|
||||
type DisplayMode =
|
||||
| "pending"
|
||||
| "interactive"
|
||||
| "poster-reduced"
|
||||
| "night-static"
|
||||
| "hidden-mobile";
|
||||
|
||||
function subscribeToMediaQuery(query: MediaQueryList, listener: () => void) {
|
||||
if (typeof query.addEventListener === "function") {
|
||||
query.addEventListener("change", listener);
|
||||
return () => query.removeEventListener("change", listener);
|
||||
}
|
||||
|
||||
query.addListener(listener);
|
||||
return () => query.removeListener(listener);
|
||||
}
|
||||
|
||||
export function PoyrazBottomRightFollower() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const pointerYRef = useRef<number | null>(null);
|
||||
const targetTimeRef = useRef(DEFAULT_TIME);
|
||||
const currentTimeRef = useRef(DEFAULT_TIME);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const lastSeekTimestampRef = useRef(0);
|
||||
const metadataReadyRef = useRef(false);
|
||||
const primedRef = useRef(false);
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>("pending");
|
||||
const [videoFailed, setVideoFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const finePointerQuery = window.matchMedia("(pointer: fine)");
|
||||
const desktopQuery = window.matchMedia("(min-width: 840px)");
|
||||
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
const updateDisplayMode = () => {
|
||||
if (!desktopQuery.matches || !finePointerQuery.matches) {
|
||||
setDisplayMode("hidden-mobile");
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.documentElement.dataset.poyrazTheme === "dark") {
|
||||
setDisplayMode("night-static");
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayMode(reducedMotionQuery.matches ? "poster-reduced" : "interactive");
|
||||
};
|
||||
|
||||
updateDisplayMode();
|
||||
const unsubscribeFinePointer = subscribeToMediaQuery(
|
||||
finePointerQuery,
|
||||
updateDisplayMode,
|
||||
);
|
||||
const unsubscribeDesktop = subscribeToMediaQuery(desktopQuery, updateDisplayMode);
|
||||
const unsubscribeReducedMotion = subscribeToMediaQuery(
|
||||
reducedMotionQuery,
|
||||
updateDisplayMode,
|
||||
);
|
||||
const themeObserver = new MutationObserver(updateDisplayMode);
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-poyraz-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFinePointer();
|
||||
unsubscribeDesktop();
|
||||
unsubscribeReducedMotion();
|
||||
themeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMode !== "interactive" || videoFailed) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const stopAnimationLoop = () => {
|
||||
if (rafIdRef.current === null) return;
|
||||
window.cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
};
|
||||
|
||||
const seekVideo = (timestamp: number) => {
|
||||
if (
|
||||
!metadataReadyRef.current ||
|
||||
video.seeking ||
|
||||
timestamp - lastSeekTimestampRef.current < SEEK_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Number.isFinite(video.duration)
|
||||
? Math.min(video.duration, TOTAL_DURATION)
|
||||
: TOTAL_DURATION;
|
||||
const nextTime = clamp(currentTimeRef.current, 0, duration);
|
||||
|
||||
if (Math.abs(video.currentTime - nextTime) < MIN_SEEK_DELTA) return;
|
||||
|
||||
try {
|
||||
video.currentTime = nextTime;
|
||||
lastSeekTimestampRef.current = timestamp;
|
||||
} catch {
|
||||
// The poster remains visible until the browser exposes seekable metadata.
|
||||
}
|
||||
};
|
||||
|
||||
const runAnimationFrame = (timestamp: number) => {
|
||||
rafIdRef.current = null;
|
||||
if (disposed || document.hidden) return;
|
||||
|
||||
const difference = targetTimeRef.current - currentTimeRef.current;
|
||||
const settled = Math.abs(difference) <= MIN_TIME_DELTA;
|
||||
currentTimeRef.current = settled
|
||||
? targetTimeRef.current
|
||||
: currentTimeRef.current + difference * SMOOTHING;
|
||||
|
||||
seekVideo(timestamp);
|
||||
|
||||
const videoNeedsSeek =
|
||||
metadataReadyRef.current &&
|
||||
(video.seeking ||
|
||||
Math.abs(video.currentTime - targetTimeRef.current) >= MIN_SEEK_DELTA);
|
||||
|
||||
if (!settled || videoNeedsSeek) {
|
||||
rafIdRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||
}
|
||||
};
|
||||
|
||||
const startAnimationLoop = () => {
|
||||
if (
|
||||
disposed ||
|
||||
document.hidden ||
|
||||
!metadataReadyRef.current ||
|
||||
rafIdRef.current !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
rafIdRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||
};
|
||||
|
||||
const returnToDefault = () => {
|
||||
pointerYRef.current = null;
|
||||
targetTimeRef.current = DEFAULT_TIME;
|
||||
startAnimationLoop();
|
||||
};
|
||||
|
||||
const primeVideo = () => {
|
||||
if (primedRef.current || !metadataReadyRef.current) return;
|
||||
primedRef.current = true;
|
||||
|
||||
const resumeTime = currentTimeRef.current;
|
||||
video.muted = true;
|
||||
void video
|
||||
.play()
|
||||
.then(() => {
|
||||
if (disposed) return;
|
||||
video.pause();
|
||||
video.currentTime = resumeTime;
|
||||
})
|
||||
.catch(() => {
|
||||
video.pause();
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
|
||||
pointerYRef.current = event.clientY;
|
||||
targetTimeRef.current = mapPointerYToTime(event.clientY, window.innerHeight);
|
||||
primeVideo();
|
||||
startAnimationLoop();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (pointerYRef.current === null) return;
|
||||
targetTimeRef.current = mapPointerYToTime(
|
||||
pointerYRef.current,
|
||||
window.innerHeight,
|
||||
);
|
||||
startAnimationLoop();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopAnimationLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
startAnimationLoop();
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
video.pause();
|
||||
metadataReadyRef.current = true;
|
||||
targetTimeRef.current = DEFAULT_TIME;
|
||||
currentTimeRef.current = DEFAULT_TIME;
|
||||
video.currentTime = clamp(DEFAULT_TIME, 0, video.duration);
|
||||
};
|
||||
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
handleLoadedMetadata();
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
document.documentElement.addEventListener("pointerleave", returnToDefault);
|
||||
window.addEventListener("blur", returnToDefault);
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
stopAnimationLoop();
|
||||
video.pause();
|
||||
metadataReadyRef.current = false;
|
||||
primedRef.current = false;
|
||||
pointerYRef.current = null;
|
||||
targetTimeRef.current = DEFAULT_TIME;
|
||||
currentTimeRef.current = DEFAULT_TIME;
|
||||
lastSeekTimestampRef.current = 0;
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
document.documentElement.removeEventListener("pointerleave", returnToDefault);
|
||||
window.removeEventListener("blur", returnToDefault);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [displayMode, videoFailed]);
|
||||
|
||||
const showVideo = displayMode === "interactive" && !videoFailed;
|
||||
const showNight = displayMode === "night-static";
|
||||
|
||||
if (displayMode === "pending" || displayMode === "hidden-mobile") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-cursor-portrait
|
||||
data-portrait-mode={showNight ? "night" : "day"}
|
||||
className={`pointer-events-none fixed right-6 bottom-0 z-40 aspect-square w-[clamp(110px,11vw,170px)] select-none ${showNight ? "bg-transparent" : "bg-white"}`}
|
||||
>
|
||||
{showNight ? (
|
||||
<>
|
||||
<Image
|
||||
src={NIGHT_SRC}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1545px) 11vw, 170px"
|
||||
draggable={false}
|
||||
className="object-contain"
|
||||
/>
|
||||
<span
|
||||
data-sleepy-z="1"
|
||||
className="animate-sleepy-z absolute top-[22%] left-[31%] z-10 font-secondary text-[clamp(11px,1vw,15px)] font-bold text-red-100 drop-shadow-[0_0_5px_rgba(248,113,113,0.75)]"
|
||||
>
|
||||
Z
|
||||
</span>
|
||||
<span
|
||||
data-sleepy-z="2"
|
||||
className="animate-sleepy-z absolute top-[13%] left-[22%] z-10 font-secondary text-[clamp(13px,1.15vw,18px)] font-bold text-red-100 drop-shadow-[0_0_6px_rgba(248,113,113,0.8)]"
|
||||
>
|
||||
Z
|
||||
</span>
|
||||
<span
|
||||
data-sleepy-z="3"
|
||||
className="animate-sleepy-z absolute top-[3%] left-[12%] z-10 font-secondary text-[clamp(15px,1.3vw,21px)] font-bold text-red-100 drop-shadow-[0_0_7px_rgba(248,113,113,0.85)]"
|
||||
>
|
||||
Z
|
||||
</span>
|
||||
</>
|
||||
) : showVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={VIDEO_SRC}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
poster={POSTER_SRC}
|
||||
draggable={false}
|
||||
disablePictureInPicture
|
||||
className="h-full w-full bg-white object-contain"
|
||||
onError={() => setVideoFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={POSTER_SRC}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1545px) 11vw, 170px"
|
||||
draggable={false}
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Badge, Card, Typography } from "poyraz-ui/atoms";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "poyraz-ui/molecules";
|
||||
import { Link } from "@/i18n/routing";
|
||||
|
||||
type ProjectCardWithPopoverProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
badge?: string;
|
||||
href?: string;
|
||||
caseStudyHref?: string;
|
||||
caseStudyLabel?: string;
|
||||
technologies: string[];
|
||||
architecture: string;
|
||||
technologiesLabel: string;
|
||||
architectureLabel: string;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
export function ProjectCardWithPopover({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
badge,
|
||||
href,
|
||||
caseStudyHref,
|
||||
caseStudyLabel,
|
||||
technologies,
|
||||
architecture,
|
||||
technologiesLabel,
|
||||
architectureLabel,
|
||||
className,
|
||||
triggerClassName,
|
||||
priority = false,
|
||||
}: ProjectCardWithPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) {
|
||||
clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showPopover = () => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const card = (
|
||||
<Card
|
||||
variant="interactive"
|
||||
className={`group relative aspect-[4/3] overflow-hidden ${className ?? ""}`}
|
||||
>
|
||||
<Image
|
||||
src={image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 767px) 224px, 224px"
|
||||
preload={priority}
|
||||
fetchPriority={priority ? "high" : "auto"}
|
||||
className="object-cover transition-transform duration-500 ease-[var(--poyraz-motion-ease-out)] group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-overlay via-overlay/20 to-transparent transition-opacity group-hover:opacity-90" />
|
||||
{badge ? (
|
||||
<Badge className="absolute left-3 top-3 z-10">{badge}</Badge>
|
||||
) : null}
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 p-4 text-primary-foreground">
|
||||
<h3 className="font-semibold leading-tight">{title}</h3>
|
||||
{description ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs opacity-80">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
const triggerClasses = `block ${triggerClassName ?? ""} text-inherit outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{caseStudyHref ? (
|
||||
<Link
|
||||
href={caseStudyHref}
|
||||
className={`${triggerClasses} no-underline`}
|
||||
onPointerEnter={showPopover}
|
||||
onPointerLeave={scheduleClose}
|
||||
onFocus={showPopover}
|
||||
onBlur={scheduleClose}
|
||||
>
|
||||
{card}
|
||||
</Link>
|
||||
) : href ? (
|
||||
<a
|
||||
href={href}
|
||||
className={`${triggerClasses} no-underline`}
|
||||
onPointerEnter={showPopover}
|
||||
onPointerLeave={scheduleClose}
|
||||
onFocus={showPopover}
|
||||
onBlur={scheduleClose}
|
||||
>
|
||||
{card}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${triggerClasses} border-0 bg-transparent p-0 text-left`}
|
||||
onPointerEnter={showPopover}
|
||||
onPointerLeave={scheduleClose}
|
||||
onFocus={showPopover}
|
||||
onBlur={scheduleClose}
|
||||
>
|
||||
{card}
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
radius="sm"
|
||||
padding="sm"
|
||||
className="w-80 max-w-[calc(100vw-1rem)]"
|
||||
onPointerEnter={showPopover}
|
||||
onPointerLeave={scheduleClose}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Typography variant="large" className="text-sm leading-tight">
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{technologiesLabel}
|
||||
</Typography>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{technologies.map((technology) => (
|
||||
<Badge key={technology} size="sm" variant="outline" className="rounded-sm">
|
||||
{technology}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography
|
||||
variant="small"
|
||||
className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{architectureLabel}
|
||||
</Typography>
|
||||
<Typography variant="small" className="text-xs leading-relaxed text-muted-foreground">
|
||||
{architecture}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{caseStudyHref && caseStudyLabel ? (
|
||||
<Link
|
||||
href={caseStudyHref}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center justify-between border-t border-border pt-3 text-xs font-semibold text-foreground transition-colors hover:text-red-600"
|
||||
>
|
||||
<span>{caseStudyLabel}</span>
|
||||
<Icon icon="mdi:arrow-right" width={15} height={15} aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonIcon,
|
||||
ButtonLabel,
|
||||
Card,
|
||||
Typography,
|
||||
} from "poyraz-ui/atoms";
|
||||
import type { ProjectCaseStudy } from "@/data/project-case-studies";
|
||||
import { Link } from "@/i18n/routing";
|
||||
|
||||
type ProjectCaseStudyContentProps = {
|
||||
project: ProjectCaseStudy;
|
||||
};
|
||||
|
||||
const TECHNOLOGY_ICONS: Record<string, string> = {
|
||||
".NET 10": "logos:dotnet",
|
||||
"Minimal APIs": "mdi:api",
|
||||
"EF Core": "logos:dotnet",
|
||||
PostgreSQL: "logos:postgresql",
|
||||
"Angular 20": "logos:angular-icon",
|
||||
"Angular Material": "simple-icons:angular",
|
||||
"Tailwind CSS": "logos:tailwindcss-icon",
|
||||
Astro: "logos:astro-icon",
|
||||
Liquid: "mdi:code-braces",
|
||||
Fluid: "mdi:water-outline",
|
||||
Docker: "logos:docker-icon",
|
||||
"Docker Compose": "logos:docker-icon",
|
||||
"Next.js": "logos:nextjs-icon",
|
||||
React: "logos:react",
|
||||
"React Native": "logos:react",
|
||||
TypeScript: "logos:typescript-icon",
|
||||
"Express.js": "skill-icons:expressjs-light",
|
||||
"AI Integrations": "mdi:robot-outline",
|
||||
"Self-hosting": "mdi:server-outline",
|
||||
Vite: "logos:vitejs",
|
||||
"Better Auth": "mdi:shield-account-outline",
|
||||
SQLite: "logos:sqlite",
|
||||
"Drizzle ORM": "simple-icons:drizzle",
|
||||
pnpm: "logos:pnpm",
|
||||
Turborepo: "logos:turborepo-icon",
|
||||
nginx: "logos:nginx",
|
||||
Dokploy: "simple-icons:dokploy",
|
||||
Supabase: "logos:supabase-icon",
|
||||
"İŞKUR API": "mdi:briefcase-search-outline",
|
||||
"Gemini AI": "logos:google-gemini",
|
||||
};
|
||||
|
||||
function getTechnologyIcon(technology: string) {
|
||||
return TECHNOLOGY_ICONS[technology] ?? "mdi:code-tags";
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="h3" className="border-b border-border pb-3">
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function BulletList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex gap-2.5 text-sm leading-7 text-foreground/80">
|
||||
<Icon
|
||||
icon="mdi:check-circle-outline"
|
||||
width={18}
|
||||
height={18}
|
||||
className="mt-1.5 shrink-0 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ProjectCaseStudyContent({
|
||||
project,
|
||||
}: ProjectCaseStudyContentProps) {
|
||||
const t = await getTranslations({
|
||||
locale: project.locale,
|
||||
namespace: "ProjectCaseStudy",
|
||||
});
|
||||
|
||||
return (
|
||||
<article className="h-full overflow-y-auto pb-12">
|
||||
<div className="mx-auto max-w-6xl space-y-10">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-1.5 rounded-sm border border-border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
|
||||
>
|
||||
<Icon icon="mdi:arrow-left" width={16} height={16} aria-hidden="true" />
|
||||
{t("back")}
|
||||
</Link>
|
||||
|
||||
<header className="grid items-center gap-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge className="rounded-sm">{project.eyebrow}</Badge>
|
||||
<Badge variant="outline" className="rounded-sm">
|
||||
{project.context}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Typography variant="h2" className="text-3xl md:text-5xl">
|
||||
{project.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="p"
|
||||
className="max-w-3xl text-base leading-8 text-muted-foreground md:text-lg"
|
||||
>
|
||||
{project.summary}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild radius="sm" effect="swap" swapTarget="both">
|
||||
<a href={project.liveUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ButtonIcon>
|
||||
<Icon icon="mdi:open-in-new" width={17} height={17} />
|
||||
</ButtonIcon>
|
||||
<ButtonLabel>{t("liveDemo")}</ButtonLabel>
|
||||
</a>
|
||||
</Button>
|
||||
{project.sourceUrl ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
effect="swap"
|
||||
swapTarget="both"
|
||||
>
|
||||
<a
|
||||
href={project.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ButtonIcon>
|
||||
<Icon icon="mdi:github" width={17} height={17} />
|
||||
</ButtonIcon>
|
||||
<ButtonLabel>{t("sourceCode")}</ButtonLabel>
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden rounded-sm border-border bg-muted/20 p-2">
|
||||
<Image
|
||||
src={project.image}
|
||||
alt={project.screenshotAlt}
|
||||
width={1080}
|
||||
height={1080}
|
||||
priority
|
||||
sizes="(max-width: 1024px) 100vw, 420px"
|
||||
className="aspect-square h-auto w-full rounded-sm object-cover"
|
||||
/>
|
||||
</Card>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("roleAndTeam")}</SectionTitle>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
label: t("role"),
|
||||
value: project.role,
|
||||
icon: "mdi:account-hard-hat",
|
||||
},
|
||||
{
|
||||
label: t("team"),
|
||||
value: project.team,
|
||||
icon: "mdi:account-group-outline",
|
||||
href: project.teamUrl,
|
||||
},
|
||||
{
|
||||
label: t("context"),
|
||||
value: project.context,
|
||||
icon: "mdi:briefcase-outline",
|
||||
},
|
||||
].map((item) => (
|
||||
<Card key={item.label} className="rounded-sm border-border p-4">
|
||||
<div className="mb-3 flex h-8 w-8 items-center justify-center rounded-sm bg-red-600/10 text-red-600">
|
||||
<Icon icon={item.icon} width={18} height={18} aria-hidden="true" />
|
||||
</div>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-start gap-1.5 text-sm font-semibold leading-6 text-foreground underline decoration-border underline-offset-4 transition-colors hover:text-red-600 hover:decoration-red-600"
|
||||
>
|
||||
<span>{item.value}</span>
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
width={14}
|
||||
height={14}
|
||||
className="mt-1 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Typography variant="large" className="mt-1 text-sm leading-6">
|
||||
{item.value}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("overview")}</SectionTitle>
|
||||
<Card className="space-y-4 rounded-sm border-border p-5 md:p-6">
|
||||
{project.overview.map((paragraph) => (
|
||||
<Typography
|
||||
key={paragraph}
|
||||
variant="p"
|
||||
className="text-sm leading-7 text-foreground/85"
|
||||
>
|
||||
{paragraph}
|
||||
</Typography>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("technologies")}</SectionTitle>
|
||||
<Card className="rounded-sm border-border p-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.technologies.map((technology) => (
|
||||
<Badge
|
||||
key={technology}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-sm px-2.5 py-1"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Icon
|
||||
icon={getTechnologyIcon(technology)}
|
||||
width={15}
|
||||
height={15}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{technology}</span>
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("problemConstraints")}</SectionTitle>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<Card className="rounded-sm border-border p-5 md:p-6">
|
||||
<Typography variant="p" className="text-sm leading-7 text-foreground/85">
|
||||
{project.problem}
|
||||
</Typography>
|
||||
</Card>
|
||||
<Card className="rounded-sm border-border p-5 md:p-6">
|
||||
<Typography variant="large" className="mb-4 text-sm">
|
||||
{t("constraints")}
|
||||
</Typography>
|
||||
<BulletList items={project.constraints} />
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("architectureDecisions")}</SectionTitle>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{project.decisions.map((decision, index) => (
|
||||
<Card key={decision.title} className="rounded-sm border-border p-5 md:p-6">
|
||||
<div className="mb-4 flex h-8 w-8 items-center justify-center rounded-sm bg-red-600 font-mono text-xs font-semibold text-white">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<Typography variant="large" className="text-base">
|
||||
{decision.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="p"
|
||||
className="mt-2 text-sm leading-7 text-muted-foreground"
|
||||
>
|
||||
{decision.description}
|
||||
</Typography>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("designProcess")}</SectionTitle>
|
||||
<Card className="grid gap-6 rounded-sm border-border p-5 md:grid-cols-[1fr_1.1fr] md:p-6">
|
||||
<Typography variant="p" className="text-sm leading-7 text-foreground/85">
|
||||
{project.designProcess}
|
||||
</Typography>
|
||||
<ol className="space-y-3 border-border md:border-l md:pl-6">
|
||||
{project.designSteps.map((step, index) => (
|
||||
<li key={step} className="flex gap-3 text-sm leading-6 text-foreground/80">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-red-600/40 bg-red-600/10 font-mono text-[10px] font-semibold text-red-600">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 lg:grid-cols-2">
|
||||
<Card className="rounded-sm border-border p-5 md:p-6">
|
||||
<div className="mb-4 flex items-center gap-2 text-amber-600">
|
||||
<Icon icon="mdi:alert-decagram-outline" width={21} height={21} />
|
||||
<Typography variant="large" className="text-base text-foreground">
|
||||
{t("challenge")}
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="p" className="text-sm leading-7 text-muted-foreground">
|
||||
{project.challenge}
|
||||
</Typography>
|
||||
</Card>
|
||||
<Card className="rounded-sm border-border p-5 md:p-6">
|
||||
<div className="mb-4 flex items-center gap-2 text-emerald-600">
|
||||
<Icon icon="mdi:lightbulb-on-outline" width={21} height={21} />
|
||||
<Typography variant="large" className="text-base text-foreground">
|
||||
{t("solution")}
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="p" className="text-sm leading-7 text-muted-foreground">
|
||||
{project.solution}
|
||||
</Typography>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("results")}</SectionTitle>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{project.results.map((result) => (
|
||||
<Card key={result.label} className="rounded-sm border-border p-5">
|
||||
<Typography className="font-mono text-3xl font-semibold text-red-600">
|
||||
{result.value}
|
||||
</Typography>
|
||||
<Typography variant="large" className="mt-2 text-sm">
|
||||
{result.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mt-1 block leading-6 text-muted-foreground"
|
||||
>
|
||||
{result.description}
|
||||
</Typography>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="block rounded-sm border border-dashed border-border px-4 py-3 leading-6 text-muted-foreground"
|
||||
>
|
||||
{project.metricsNote}
|
||||
</Typography>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("screenshots")}</SectionTitle>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{project.screenshots.map((screenshot) => (
|
||||
<a
|
||||
key={screenshot.src}
|
||||
href={screenshot.src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${screenshot.alt} — ${t("openScreenshot")}`}
|
||||
className="group block no-underline text-inherit"
|
||||
>
|
||||
<Card className="h-full overflow-hidden rounded-sm border-border transition-colors group-hover:border-red-600/40">
|
||||
<div className="relative aspect-video overflow-hidden bg-muted/20">
|
||||
<Image
|
||||
src={screenshot.src}
|
||||
alt={screenshot.alt}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
className="object-cover object-top transition-transform duration-500 ease-out group-hover:scale-[1.015]"
|
||||
/>
|
||||
<span className="absolute top-3 right-3 inline-flex h-8 w-8 items-center justify-center rounded-sm border border-white/20 bg-black/65 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<Icon icon="mdi:arrow-expand" width={17} height={17} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="block border-t border-border px-4 py-3 leading-6 text-muted-foreground"
|
||||
>
|
||||
{screenshot.caption}
|
||||
</Typography>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<SectionTitle>{t("repository")}</SectionTitle>
|
||||
<Card className="flex items-start gap-3 rounded-sm border-border p-5">
|
||||
<Icon
|
||||
icon={project.sourceUrl ? "mdi:source-repository" : "mdi:lock-outline"}
|
||||
width={20}
|
||||
height={20}
|
||||
className="mt-0.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Typography variant="small" className="leading-6 text-muted-foreground">
|
||||
{project.sourceNote}
|
||||
</Typography>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import Image from "next/image";
|
||||
import { Link } from "@/i18n/routing";
|
||||
import { Icon } from "@iconify/react";
|
||||
import {
|
||||
@@ -9,8 +8,9 @@ import {
|
||||
Card,
|
||||
Typography,
|
||||
} from "poyraz-ui/atoms";
|
||||
import { ImageCard } from "poyraz-ui/molecules";
|
||||
import { StaggerContainer, StaggerItem } from "@/components/motion-wrapper";
|
||||
import { GithubContributionGraph } from "@/components/github-contribution-graph";
|
||||
import { ProjectCardWithPopover } from "@/components/project-card-with-popover";
|
||||
import {
|
||||
EXTENSIONS,
|
||||
FIGMA_TEMPLATES,
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
WEB_APPS,
|
||||
type ProjectItem,
|
||||
} from "@/data/projects";
|
||||
import { getGithubRepos, getNpmPackages } from "@/lib/project-feeds";
|
||||
import {
|
||||
getGithubContributions,
|
||||
getGithubRepos,
|
||||
getNpmPackages,
|
||||
} from "@/lib/project-feeds";
|
||||
import { getTranslations, getLocale } from "next-intl/server";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
|
||||
@@ -64,16 +68,25 @@ type LocalizedProjectItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
technologies: string[];
|
||||
architecture: string;
|
||||
badge?: string;
|
||||
href?: string;
|
||||
caseStudySlug?: string;
|
||||
};
|
||||
|
||||
function ProjectSection({
|
||||
title,
|
||||
items,
|
||||
technologiesLabel,
|
||||
architectureLabel,
|
||||
caseStudyLabel,
|
||||
}: {
|
||||
title: string;
|
||||
items: LocalizedProjectItem[];
|
||||
technologiesLabel: string;
|
||||
architectureLabel: string;
|
||||
caseStudyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
@@ -83,12 +96,23 @@ function ProjectSection({
|
||||
<StaggerContainer className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<StaggerItem key={item.id}>
|
||||
<ImageCard
|
||||
<ProjectCardWithPopover
|
||||
image={item.image}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
badge={item.badge}
|
||||
href={item.href}
|
||||
caseStudyHref={
|
||||
item.caseStudySlug
|
||||
? `/projects/${item.caseStudySlug}`
|
||||
: undefined
|
||||
}
|
||||
caseStudyLabel={caseStudyLabel}
|
||||
technologies={item.technologies}
|
||||
architecture={item.architecture}
|
||||
technologiesLabel={technologiesLabel}
|
||||
architectureLabel={architectureLabel}
|
||||
triggerClassName="w-full"
|
||||
className="aspect-square rounded-sm border-border"
|
||||
/>
|
||||
</StaggerItem>
|
||||
@@ -102,15 +126,18 @@ export async function ProjectsContent() {
|
||||
const t = await getTranslations("Projects");
|
||||
const locale = await getLocale();
|
||||
|
||||
const [repos, npmPackages] = await Promise.all([
|
||||
const [repos, npmPackages, contributions] = await Promise.all([
|
||||
getGithubRepos(),
|
||||
getNpmPackages(),
|
||||
getGithubContributions(),
|
||||
]);
|
||||
|
||||
const localizeItems = (items: ProjectItem[]): LocalizedProjectItem[] => {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
title: getLocalizedValue(item.title, locale),
|
||||
description: getLocalizedValue(item.description, locale),
|
||||
architecture: getLocalizedValue(item.architecture, locale),
|
||||
badge: item.badge ? getLocalizedValue(item.badge, locale) : undefined,
|
||||
}));
|
||||
};
|
||||
@@ -118,22 +145,47 @@ export async function ProjectsContent() {
|
||||
return (
|
||||
<section className="flex h-full flex-col gap-8 overflow-y-auto md:gap-10">
|
||||
<Card className="rounded-sm border-border bg-background p-2">
|
||||
<div className="overflow-x-auto rounded-sm">
|
||||
<Image
|
||||
src="https://ghchart.rshah.org/dc2626/poyrazavsever"
|
||||
alt="poyrazavsever için GitHub katkı grafiği"
|
||||
width={820}
|
||||
height={120}
|
||||
className="h-auto w-full min-w-[740px]"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<GithubContributionGraph
|
||||
days={contributions}
|
||||
locale={locale}
|
||||
labels={{
|
||||
calendar: t("contributionCalendar"),
|
||||
unavailable: t("contributionUnavailable"),
|
||||
none: t("noContributions"),
|
||||
singular: t("contributionSingular"),
|
||||
plural: t("contributionPlural"),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ProjectSection title={t("sections.mobileApps")} items={localizeItems(MOBILE_APPS)} />
|
||||
<ProjectSection title={t("sections.webApps")} items={localizeItems(WEB_APPS)} />
|
||||
<ProjectSection title={t("sections.extensions")} items={localizeItems(EXTENSIONS)} />
|
||||
<ProjectSection title={t("sections.figmaTemplates")} items={localizeItems(FIGMA_TEMPLATES)} />
|
||||
<ProjectSection
|
||||
title={t("sections.webApps")}
|
||||
items={localizeItems(WEB_APPS)}
|
||||
technologiesLabel={t("technologies")}
|
||||
architectureLabel={t("architecture")}
|
||||
caseStudyLabel={t("viewCaseStudy")}
|
||||
/>
|
||||
<ProjectSection
|
||||
title={t("sections.mobileApps")}
|
||||
items={localizeItems(MOBILE_APPS)}
|
||||
technologiesLabel={t("technologies")}
|
||||
architectureLabel={t("architecture")}
|
||||
caseStudyLabel={t("viewCaseStudy")}
|
||||
/>
|
||||
<ProjectSection
|
||||
title={t("sections.extensions")}
|
||||
items={localizeItems(EXTENSIONS)}
|
||||
technologiesLabel={t("technologies")}
|
||||
architectureLabel={t("architecture")}
|
||||
caseStudyLabel={t("viewCaseStudy")}
|
||||
/>
|
||||
<ProjectSection
|
||||
title={t("sections.figmaTemplates")}
|
||||
items={localizeItems(FIGMA_TEMPLATES)}
|
||||
technologiesLabel={t("technologies")}
|
||||
architectureLabel={t("architecture")}
|
||||
caseStudyLabel={t("viewCaseStudy")}
|
||||
/>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
|
||||
import { TestimonialCard } from "poyraz-ui/molecules";
|
||||
import Image from "next/image";
|
||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
||||
import { StarRating } from "poyraz-ui/molecules";
|
||||
import { REFERENCES } from "@/data/references";
|
||||
import { useLocale } from "next-intl";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
@@ -27,14 +29,41 @@ export function ReferenceCards({
|
||||
const quoteText = getLocalizedValue(item.quote, locale);
|
||||
|
||||
const card = (
|
||||
<TestimonialCard
|
||||
quote={quoteText}
|
||||
author={item.author}
|
||||
role={getLocalizedValue(item.role, locale)}
|
||||
avatar={item.avatar}
|
||||
rating={showRating ? item.rating : undefined}
|
||||
className={`flex flex-col rounded-sm ${lineClamp ? "[&_blockquote]:line-clamp-4" : ""} ${hoverClassName} ${cardClassName || "w-70 shrink-0"}`}
|
||||
/>
|
||||
<Card
|
||||
className={`group flex h-full flex-col rounded-sm ${hoverClassName} ${cardClassName || "w-70 shrink-0"}`}
|
||||
>
|
||||
<CardContent className="flex h-full flex-1 flex-col p-5">
|
||||
<span className="font-secondary text-4xl leading-none text-primary">
|
||||
“
|
||||
</span>
|
||||
<blockquote
|
||||
className={`mt-1 text-sm leading-relaxed text-secondary-foreground ${lineClamp ? "line-clamp-4" : ""}`}
|
||||
>
|
||||
{quoteText}
|
||||
</blockquote>
|
||||
{showRating && item.rating != null ? (
|
||||
<StarRating rating={item.rating} className="mt-3" />
|
||||
) : null}
|
||||
<div className="mt-auto flex items-center gap-3 border-t border-border pt-4">
|
||||
<Image
|
||||
src={item.avatar}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
sizes="36px"
|
||||
className="size-9 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">
|
||||
{item.author}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{getLocalizedValue(item.role, locale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const href = item.documentHref || item.profileHref;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "poyraz-ui/molecules";
|
||||
import {
|
||||
getCommandPaletteGroups,
|
||||
type AnimationSourceSearchItem,
|
||||
type CommandPaletteItem as PaletteItem,
|
||||
} from "@/lib/command-palette-links";
|
||||
import { useKeyboardShortcutLabel } from "@/lib/use-keyboard-shortcut-label";
|
||||
@@ -24,9 +25,14 @@ import { useKeyboardShortcutLabel } from "@/lib/use-keyboard-shortcut-label";
|
||||
type SearchCommandProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
animationSources: AnimationSourceSearchItem[];
|
||||
};
|
||||
|
||||
export function SearchCommand({ open, onOpenChange }: SearchCommandProps) {
|
||||
export function SearchCommand({
|
||||
open,
|
||||
onOpenChange,
|
||||
animationSources,
|
||||
}: SearchCommandProps) {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const tLinks = useTranslations("Links");
|
||||
@@ -36,8 +42,8 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return getCommandPaletteGroups(locale, tLinks, tNav);
|
||||
}, [locale, tLinks, tNav]);
|
||||
return getCommandPaletteGroups(locale, tLinks, tNav, animationSources);
|
||||
}, [animationSources, locale, tLinks, tNav]);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
|
||||
+321
-102
@@ -5,7 +5,7 @@ import dynamic from "next/dynamic";
|
||||
import { Link, usePathname } from "@/i18n/routing";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
@@ -25,9 +25,6 @@ import {
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@@ -35,8 +32,15 @@ import {
|
||||
} from "poyraz-ui/molecules";
|
||||
import { NavbarTopBar, NavbarTopBarSection } from "poyraz-ui/organisms";
|
||||
import { useKeyboardShortcutLabel } from "@/lib/use-keyboard-shortcut-label";
|
||||
import { getResumeHref, NAV_LINKS, SOCIAL_LINKS, TOP_ICON_LINKS } from "@/lib/links";
|
||||
import {
|
||||
getResumeHref,
|
||||
NAV_DROPDOWN_GROUPS,
|
||||
NAV_LINKS,
|
||||
SOCIAL_LINKS,
|
||||
TOP_ICON_LINKS,
|
||||
} from "@/lib/links";
|
||||
import type { ThemeMode } from "@/components/app-shell";
|
||||
import type { AnimationSourceSearchItem } from "@/lib/command-palette-links";
|
||||
|
||||
const SearchCommand = dynamic(
|
||||
() => import("@/components/search-command").then((mod) => mod.SearchCommand),
|
||||
@@ -44,6 +48,10 @@ const SearchCommand = dynamic(
|
||||
);
|
||||
|
||||
const slowShineClassName = "[--poyraz-motion-duration-deliberate:1100ms]";
|
||||
const dropdownItemLinkClassName =
|
||||
"grid w-full grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-2";
|
||||
const mobileDropdownItemLinkClassName =
|
||||
"grid min-h-10 w-full grid-cols-[1.25rem_minmax(0,1fr)_1rem] items-center gap-3 rounded-sm px-2 py-2 text-sm text-foreground/70 transition-colors hover:bg-muted hover:text-foreground";
|
||||
|
||||
function getNavLinkClass(isActive: boolean) {
|
||||
return [
|
||||
@@ -54,6 +62,27 @@ function getNavLinkClass(isActive: boolean) {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function getDesktopDropdownTriggerClass(isActive: boolean) {
|
||||
return [
|
||||
"relative inline-flex h-7 shrink-0 cursor-pointer items-center justify-center gap-1 whitespace-nowrap rounded-sm px-2.5 text-xs font-medium outline-none",
|
||||
"transition-[color,background-color] duration-[var(--poyraz-motion-duration-fast)] ease-[var(--poyraz-motion-ease-out)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function getDesktopNavLinkClass(isActive: boolean) {
|
||||
return [
|
||||
"relative inline-flex h-9 shrink-0 items-center justify-center whitespace-nowrap rounded-sm px-2.5 text-xs font-medium outline-none",
|
||||
"transition-[color,background-color] duration-[var(--poyraz-motion-duration-fast)] ease-[var(--poyraz-motion-ease-out)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:rounded-full after:transition-colors",
|
||||
isActive
|
||||
? "text-foreground after:bg-primary"
|
||||
: "text-muted-foreground after:bg-transparent hover:text-foreground",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
type ThemeToggleProps = {
|
||||
theme: ThemeMode;
|
||||
onThemeChange: (theme: ThemeMode) => void;
|
||||
@@ -83,11 +112,19 @@ function ThemeToggle({ theme, onThemeChange }: ThemeToggleProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type SiteNavbarProps = ThemeToggleProps;
|
||||
type SiteNavbarProps = ThemeToggleProps & {
|
||||
animationSources: AnimationSourceSearchItem[];
|
||||
};
|
||||
|
||||
export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
export function SiteNavbar({
|
||||
theme,
|
||||
onThemeChange,
|
||||
animationSources,
|
||||
}: SiteNavbarProps) {
|
||||
const pathname = usePathname();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [mobileMenuGroupId, setMobileMenuGroupId] = useState<string | null>(null);
|
||||
const shortcut = useKeyboardShortcutLabel();
|
||||
const t = useTranslations("Nav");
|
||||
const locale = useLocale();
|
||||
@@ -97,15 +134,22 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
const activeTab = NAV_LINKS.find((item) => isActiveLink(item.href))?.id;
|
||||
const activeMobileMenuGroup = NAV_DROPDOWN_GROUPS.find(
|
||||
(group) => group.id === mobileMenuGroupId,
|
||||
);
|
||||
|
||||
const handleMobileMenuOpenChange = (open: boolean) => {
|
||||
setMobileMenuOpen(open);
|
||||
if (!open) setMobileMenuGroupId(null);
|
||||
};
|
||||
const languageLabel = locale === "tr" ? "Switch to English" : "Türkçe'ye geç";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<NavbarTopBar
|
||||
variant="secondary"
|
||||
className="border-0 bg-transparent p-0 shadow-none"
|
||||
className="border-0 bg-transparent p-0 shadow-none [&>div]:max-w-none [&>div]:px-0"
|
||||
>
|
||||
<NavbarTopBarSection align="end" className="gap-2">
|
||||
{TOP_ICON_LINKS.map((item) => {
|
||||
@@ -161,14 +205,14 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
</NavbarTopBar>
|
||||
</TooltipProvider>
|
||||
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<header className="flex min-w-0 items-center justify-between gap-3 border-b border-border pb-4">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Ana sayfaya git"
|
||||
className="inline-flex items-center"
|
||||
className="inline-flex shrink-0 items-center"
|
||||
>
|
||||
<Logo
|
||||
src="/logo/logo.png"
|
||||
src="/logo/logo-96.webp"
|
||||
alt="Poyraz Avsever"
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -179,40 +223,109 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
<Tabs value={activeTab ?? ""} className="w-auto">
|
||||
<TabsList
|
||||
variant="line"
|
||||
radius="sm"
|
||||
className="h-9 gap-1 bg-transparent p-0"
|
||||
aria-label="Ana navigasyon"
|
||||
>
|
||||
<div className="hidden min-w-0 flex-1 items-center justify-end gap-2 min-[840px]:flex">
|
||||
<nav
|
||||
aria-label={locale === "tr" ? "Ana navigasyon" : "Main navigation"}
|
||||
className="flex h-9 w-auto shrink-0 items-center gap-1"
|
||||
>
|
||||
{NAV_LINKS.map((item, index) => (
|
||||
<div key={item.id} className="flex items-center gap-1.5">
|
||||
{index > 0 && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-4 bg-border/70"
|
||||
decorative
|
||||
/>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value={item.id}
|
||||
asChild
|
||||
size="sm"
|
||||
radius="sm"
|
||||
className="cursor-pointer px-2.5"
|
||||
>
|
||||
<Link href={item.href}>{t(item.id)}</Link>
|
||||
</TabsTrigger>
|
||||
</div>
|
||||
<Fragment key={item.id}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{index > 0 && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-4 bg-border/70"
|
||||
decorative
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={isActiveLink(item.href) ? "page" : undefined}
|
||||
className={getDesktopNavLinkClass(
|
||||
isActiveLink(item.href),
|
||||
)}
|
||||
>
|
||||
{t(item.id)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{NAV_DROPDOWN_GROUPS.filter(
|
||||
(group) => group.insertAfter === item.id,
|
||||
).map((group) => (
|
||||
<div key={group.id} className="flex items-center gap-1.5">
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-4 bg-border/70"
|
||||
decorative
|
||||
/>
|
||||
<DropdownMenu interaction="click">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={getDesktopDropdownTriggerClass(
|
||||
group.items.some(
|
||||
(groupItem) =>
|
||||
!groupItem.external &&
|
||||
isActiveLink(groupItem.href.split("?")[0]),
|
||||
),
|
||||
)}
|
||||
>
|
||||
<span>{t(group.id)}</span>
|
||||
<Icon icon="mdi:chevron-down" width={14} height={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
surface="solid"
|
||||
radius="md"
|
||||
itemSize="md"
|
||||
itemRadius="sm"
|
||||
className="w-56 bg-popover"
|
||||
>
|
||||
{group.items.map((groupItem) => (
|
||||
<DropdownMenuItem key={groupItem.id} asChild>
|
||||
{groupItem.external ? (
|
||||
<a
|
||||
href={groupItem.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={dropdownItemLinkClassName}
|
||||
>
|
||||
<Icon
|
||||
icon={groupItem.icon}
|
||||
width={16}
|
||||
height={16}
|
||||
className="block justify-self-center"
|
||||
/>
|
||||
<span className="min-w-0 leading-5">{t(groupItem.id)}</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={groupItem.href}
|
||||
className={dropdownItemLinkClassName}
|
||||
>
|
||||
<Icon
|
||||
icon={groupItem.icon}
|
||||
width={16}
|
||||
height={16}
|
||||
className="block justify-self-center"
|
||||
/>
|
||||
<span className="min-w-0 leading-5">{t(groupItem.id)}</span>
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</nav>
|
||||
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-5 bg-border"
|
||||
className="h-5 shrink-0 bg-border"
|
||||
decorative
|
||||
/>
|
||||
<Button
|
||||
@@ -221,7 +334,7 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
radius="sm"
|
||||
effect="shine"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className={`h-9 w-44 cursor-pointer justify-between px-3 text-sm sm:w-52 ${slowShineClassName}`}
|
||||
className={`h-9 w-auto cursor-pointer px-3 text-sm ${slowShineClassName}`}
|
||||
aria-label={t("search")}
|
||||
>
|
||||
<ButtonIcon>
|
||||
@@ -273,7 +386,7 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<div className="flex items-center gap-2 min-[840px]:hidden">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -287,7 +400,7 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
<Icon icon="mdi:magnify" width={16} height={16} />
|
||||
</Button>
|
||||
|
||||
<Sheet>
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={handleMobileMenuOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -303,69 +416,175 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-72 p-4">
|
||||
<SheetTitle className="sr-only">{t("mobileMenu")}</SheetTitle>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
radius="sm"
|
||||
effect="shine"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className={`w-full cursor-pointer justify-between ${slowShineClassName}`}
|
||||
aria-label={t("search")}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon icon="mdi:magnify" width={16} height={16} />
|
||||
<span>{t("search")}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
{shortcut}
|
||||
</span>
|
||||
</Button>
|
||||
</SheetClose>
|
||||
|
||||
<Separator className="bg-border" decorative />
|
||||
|
||||
<nav aria-label="Mobil navigasyon">
|
||||
<ul className="space-y-2">
|
||||
{NAV_LINKS.map((item) => (
|
||||
<li key={item.id}>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={getNavLinkClass(isActiveLink(item.href))}
|
||||
>
|
||||
{t(item.id)}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<Separator className="bg-border" decorative />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SOCIAL_LINKS.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-sm border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
{activeMobileMenuGroup ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-h-9 items-center gap-2 pr-9">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
radius="sm"
|
||||
effect="shine"
|
||||
className={`shrink-0 cursor-pointer ${slowShineClassName}`}
|
||||
aria-label={t("backToMenu")}
|
||||
onClick={() => setMobileMenuGroupId(null)}
|
||||
>
|
||||
<Icon icon={item.icon} width={14} height={14} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<Icon icon="mdi:chevron-left" width={18} height={18} />
|
||||
</Button>
|
||||
<SheetTitle className="truncate text-base font-medium">
|
||||
{t(activeMobileMenuGroup.id)}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-border" decorative />
|
||||
|
||||
<nav aria-label={t(activeMobileMenuGroup.id)}>
|
||||
<ul className="space-y-1">
|
||||
{activeMobileMenuGroup.items.map((groupItem) => (
|
||||
<li key={groupItem.id}>
|
||||
{groupItem.external ? (
|
||||
<SheetClose asChild>
|
||||
<a
|
||||
href={groupItem.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={mobileDropdownItemLinkClassName}
|
||||
>
|
||||
<Icon
|
||||
icon={groupItem.icon}
|
||||
width={18}
|
||||
height={18}
|
||||
className="block justify-self-center"
|
||||
/>
|
||||
<span className="min-w-0 leading-5">{t(groupItem.id)}</span>
|
||||
<Icon
|
||||
icon="mdi:open-in-new"
|
||||
width={14}
|
||||
height={14}
|
||||
className="block justify-self-center text-muted-foreground"
|
||||
/>
|
||||
</a>
|
||||
</SheetClose>
|
||||
) : (
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href={groupItem.href}
|
||||
className={mobileDropdownItemLinkClassName}
|
||||
>
|
||||
<Icon
|
||||
icon={groupItem.icon}
|
||||
width={18}
|
||||
height={18}
|
||||
className="block justify-self-center"
|
||||
/>
|
||||
<span className="min-w-0 leading-5">{t(groupItem.id)}</span>
|
||||
<Icon
|
||||
icon="mdi:chevron-right"
|
||||
width={16}
|
||||
height={16}
|
||||
className="block justify-self-center text-muted-foreground"
|
||||
/>
|
||||
</Link>
|
||||
</SheetClose>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SheetTitle className="sr-only">{t("mobileMenu")}</SheetTitle>
|
||||
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
radius="sm"
|
||||
effect="shine"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className={`w-full cursor-pointer justify-between ${slowShineClassName}`}
|
||||
aria-label={t("search")}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon icon="mdi:magnify" width={16} height={16} />
|
||||
<span>{t("search")}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
{shortcut}
|
||||
</span>
|
||||
</Button>
|
||||
</SheetClose>
|
||||
|
||||
<Separator className="bg-border" decorative />
|
||||
|
||||
<nav aria-label="Mobil navigasyon">
|
||||
<ul className="space-y-2">
|
||||
{NAV_LINKS.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<li>
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={getNavLinkClass(isActiveLink(item.href))}
|
||||
>
|
||||
{t(item.id)}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
</li>
|
||||
|
||||
{NAV_DROPDOWN_GROUPS.filter(
|
||||
(group) => group.insertAfter === item.id,
|
||||
).map((group) => (
|
||||
<li key={group.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${getNavLinkClass(
|
||||
group.items.some(
|
||||
(groupItem) =>
|
||||
!groupItem.external &&
|
||||
isActiveLink(groupItem.href.split("?")[0]),
|
||||
),
|
||||
)} w-full cursor-pointer items-center justify-between gap-3 text-left`}
|
||||
onClick={() => setMobileMenuGroupId(group.id)}
|
||||
>
|
||||
<span>{t(group.id)}</span>
|
||||
<Icon icon="mdi:chevron-right" width={16} height={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<Separator className="bg-border" decorative />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SOCIAL_LINKS.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-sm border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon icon={item.icon} width={14} height={14} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<SearchCommand open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
<SearchCommand
|
||||
open={searchOpen}
|
||||
onOpenChange={setSearchOpen}
|
||||
animationSources={animationSources}
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
---
|
||||
title: "How to Build an AI Avatar That Follows the Pointer"
|
||||
slug: "poyraz-cursor-portrait"
|
||||
excerpt: "Build the effect from start to finish with the prompts, Wiro AI and MiniMax H3 video workflow, FFmpeg preparation, and React integration I used."
|
||||
coverImage: "/animation-sources/poyraz-cursor-portrait/avatar-mouse-follow.webp"
|
||||
platform: "Web"
|
||||
tools:
|
||||
- "Wiro AI"
|
||||
- "MiniMax H3"
|
||||
- "FFmpeg"
|
||||
- "React"
|
||||
date: "2026-08-29"
|
||||
author: "Poyraz Avsever"
|
||||
lang: "en"
|
||||
---
|
||||
|
||||
The portrait at the bottom-right is not playing like a normal video. It remains paused while the pointer's vertical position controls the video timeline. At the bottom of the screen the portrait looks down-left, in the middle it looks horizontally left, and at the top it looks up-left.
|
||||
|
||||
I generated the video with the **MiniMax H3** model through **Wiro AI** in a **1:1 square format**. I then prepared it for frequent seeking with FFmpeg and connected its `currentTime` to the pointer's Y position in React.
|
||||
|
||||
Every prompt on this page is copyable and can be adapted to your portrait, avatar, or brand character.
|
||||
|
||||
## How to use the `[[...]]` fields
|
||||
|
||||
Double square brackets mark values you must replace. Do not leave `[[OUTFIT]]` in the final prompt; replace it with a concrete value such as `plain red polo shirt`.
|
||||
|
||||
| Variable | What it means | Value in this project |
|
||||
| --- | --- | --- |
|
||||
| `[[SUBJECT]]` | Person or character | young male software creator |
|
||||
| `[[OUTFIT]]` | Clothing | plain red polo shirt |
|
||||
| `[[BACKGROUND_COLOR]]` | Flat background | pure white, `#FFFFFF` |
|
||||
| `[[EXPRESSION]]` | Fixed expression | calm, natural, neutral |
|
||||
| `[[ASPECT_RATIO]]` | Generation ratio | `1:1` |
|
||||
| `[[HEAD_DIRECTION]]` | Fixed horizontal angle | about 60 degrees left |
|
||||
| `[[VIDEO_PATH]]` | Public video path | `/media/cursor-portrait/poyraz-bottom-right.mp4` |
|
||||
| `[[POSTER_PATH]]` | Public poster path | `/media/cursor-portrait/poyraz-bottom-right-poster.webp` |
|
||||
| `[[FRAMEWORK]]` | Application stack | Next.js, React, TypeScript |
|
||||
| `[[STYLING_SYSTEM]]` | Styling stack | Tailwind CSS |
|
||||
|
||||
Search for every `[[...]]` field before submitting a prompt and make sure no unresolved variable remains.
|
||||
|
||||
## How the effect works
|
||||
|
||||
The reliable way to control a single video in real time is to treat it as a short **motion-control plate**, not as an autoplaying clip.
|
||||
|
||||
The four-second timeline in this project:
|
||||
|
||||
1. `0.00–0.25`: hold the down-left pose.
|
||||
2. `0.25–3.75`: move from down-left to up-left.
|
||||
3. Around `2.00`: reach the neutral horizontal-left pose.
|
||||
4. `3.75–4.00`: hold the up-left pose.
|
||||
|
||||
Moving the pointer vertically scrubs this active range forward or backward. Pointer X is intentionally ignored because the generated video contains only one controlled motion axis.
|
||||
|
||||
> A single video is reliable only along the motion axis it contains. For true horizontal and vertical tracking, use a consistent 3×3 set of directional stills instead of inventing a second axis in code.
|
||||
|
||||
## Production workflow
|
||||
|
||||
1. Select a clear, front-facing identity reference.
|
||||
2. Prepare a consistent 1:1 master frame with fixed clothing, light, and background.
|
||||
3. Upload the master frame to Wiro AI and generate the motion with MiniMax H3.
|
||||
4. Regenerate with the repair prompt if the face, camera, or background drifts.
|
||||
5. Convert the result into a seek-friendly 720×720 H.264 web asset.
|
||||
6. Map pointer Y to the video's active time range.
|
||||
7. Test desktop, reduced-motion, dark-theme, and mobile behavior separately.
|
||||
|
||||
## 1. Master frame prompt
|
||||
|
||||
Upload a clear identity reference to your image-generation tool and replace every `[[...]]` field first.
|
||||
|
||||
```prompt
|
||||
Use the uploaded image only as the identity reference for [[SUBJECT]].
|
||||
Create a new photorealistic, production-ready 1:1 studio portrait for an
|
||||
interactive website animation. Preserve the exact recognizable identity,
|
||||
facial proportions, skin tone, hairstyle, hairline, eyebrows, eye shape,
|
||||
nose, lips, jawline, age, and overall appearance.
|
||||
|
||||
Composition:
|
||||
- Square [[ASPECT_RATIO]] frame.
|
||||
- Medium close-up from [[CROP_POINT]] upward.
|
||||
- Keep the full head, hair, ears, neck, shoulders, and visible upper torso
|
||||
safely inside the frame.
|
||||
- Keep comfortable negative space around the hair and shoulders.
|
||||
- The shoulders remain stable and the head is turned approximately
|
||||
[[HEAD_DIRECTION]].
|
||||
- Expression: [[EXPRESSION]].
|
||||
- Outfit: [[OUTFIT]].
|
||||
|
||||
Background and light:
|
||||
- Perfectly flat, seamless [[BACKGROUND_COLOR]] background.
|
||||
- No gradient, texture, horizon line, furniture, props, text, watermark,
|
||||
logo, border, or visible cast shadow.
|
||||
- Soft, bright studio lighting with natural skin texture.
|
||||
- Keep hair, ears, face, shoulders, and clothing edges clean.
|
||||
|
||||
Continuity constraints:
|
||||
- Do not beautify, age, de-age, stylize, or reinterpret the person.
|
||||
- Do not change facial hair, outfit, accessories, body proportions, or light.
|
||||
- Do not crop the hair, ears, shoulders, or upper torso.
|
||||
- Generate one person and one clean master frame only.
|
||||
```
|
||||
|
||||
Values used for this implementation:
|
||||
|
||||
```text
|
||||
[[SUBJECT]] = a young male software creator
|
||||
[[ASPECT_RATIO]] = 1:1
|
||||
[[CROP_POINT]] = mid-torso
|
||||
[[HEAD_DIRECTION]] = 60 degrees toward screen-left
|
||||
[[EXPRESSION]] = calm, natural, neutral expression
|
||||
[[OUTFIT]] = plain red polo shirt
|
||||
[[BACKGROUND_COLOR]] = pure white (#FFFFFF)
|
||||
```
|
||||
|
||||
## 2. Wiro AI / MiniMax H3 video prompt
|
||||
|
||||
Use the master frame as the image reference in Wiro AI with the MiniMax H3 model. The goal is a technical plate that works frame by frame, not a cinematic scene.
|
||||
|
||||
```prompt
|
||||
Animate the uploaded 1:1 master frame into a precise four-second motion-control
|
||||
plate for an interactive website portrait. Preserve the exact identity, face,
|
||||
hairstyle, red polo shirt, body proportions, lighting, colors, square framing,
|
||||
and pure white background from the reference image.
|
||||
|
||||
Output:
|
||||
- Duration: exactly 4.0 seconds.
|
||||
- Aspect ratio: 1:1.
|
||||
- One continuous shot with a completely locked, eye-level camera.
|
||||
- No zoom, crop change, pan, tilt, dolly, reframing, or camera shake.
|
||||
- No speech and no audio-dependent movement.
|
||||
|
||||
Head direction:
|
||||
- Keep the subject turned approximately 60 degrees toward screen-left for
|
||||
the entire video.
|
||||
- The horizontal head angle must not change.
|
||||
- Never turn toward the camera and never rotate into a full side profile.
|
||||
|
||||
Exact motion timeline:
|
||||
- 0.00–0.25 seconds: hold a clean down-left gaze and head-tilt pose.
|
||||
- 0.25–3.75 seconds: move smoothly and continuously from down-left to up-left.
|
||||
- At exactly 2.00 seconds: reach a neutral horizontal-left gaze.
|
||||
- 3.75–4.00 seconds: hold the final up-left pose perfectly still.
|
||||
|
||||
Movement rules:
|
||||
- Only the eyes and the minimum natural head/neck tilt required for the
|
||||
vertical gaze may move.
|
||||
- Shoulders, torso, arms, clothing, body position, head scale, and horizontal
|
||||
head angle remain fixed.
|
||||
- Keep the mouth closed and motionless.
|
||||
- No talking, smiling, eyebrow movement, nodding, leaning, body sway,
|
||||
breathing motion, or secondary gesture.
|
||||
- Movement must be slow, linear, anatomically coherent, and usable when
|
||||
scrubbed both forward and backward.
|
||||
|
||||
Continuity:
|
||||
- Preserve the same recognizable face in every frame.
|
||||
- Keep hair volume, hairline, ears, nose, jaw, skin texture, clothing folds,
|
||||
and lighting stable.
|
||||
- No face drift, morphing, warped anatomy, flicker, or changing expression.
|
||||
- Keep the background perfectly uniform pure white (#FFFFFF) in every frame.
|
||||
|
||||
This is not a cinematic scene. It is a deterministic frame-scrubbing asset
|
||||
for a website and every intermediate frame must work as a clean still image.
|
||||
```
|
||||
|
||||
Inspect the middle frames as carefully as the endpoints. Face shape, ears, hairline, and clothing edges must remain stable throughout the MiniMax H3 output.
|
||||
|
||||
## 3. Repair prompt
|
||||
|
||||
Describe the failed generation precisely in `[[OBSERVED_PROBLEMS]]`.
|
||||
|
||||
```prompt
|
||||
Regenerate this clip as a strict technical motion plate. The previous result
|
||||
is unusable because: [[OBSERVED_PROBLEMS]].
|
||||
|
||||
Lock every property except the intended vertical gaze and head-tilt movement:
|
||||
- preserve the exact identity and facial proportions in every frame;
|
||||
- keep the horizontal head angle fixed at approximately 60 degrees left;
|
||||
- fixed camera, crop, focal length, scale, head position, shoulders, torso,
|
||||
arms, outfit, expression, lighting, and background;
|
||||
- one slow linear movement from down-left to up-left;
|
||||
- neutral horizontal-left pose at exactly two seconds;
|
||||
- closed and motionless mouth;
|
||||
- no speech, smile, blink during movement, eyebrow motion, body sway,
|
||||
zoom, parallax, lighting shift, background flicker, face morphing,
|
||||
hair change, ear deformation, or new objects;
|
||||
- perfectly uniform pure white (#FFFFFF) background.
|
||||
|
||||
This clip will be paused and scrubbed frame by frame. Every intermediate frame
|
||||
must remain anatomically coherent and visually consistent with the reference.
|
||||
```
|
||||
|
||||
Example problem description:
|
||||
|
||||
```text
|
||||
[[OBSERVED_PROBLEMS]] = the face changes near the final pose, the shoulders
|
||||
move with the head, and the white background flickers between frames
|
||||
```
|
||||
|
||||
## 4. Preparing the video for the web
|
||||
|
||||
AI video can play directly in a browser, but codec and keyframe interval matter when `currentTime` changes frequently. I prepared a 720×720, 30 FPS, silent H.264 file with every frame encoded as a keyframe.
|
||||
|
||||
```bash
|
||||
ffmpeg -i INPUT.mp4 \
|
||||
-vf "scale=720:720:force_original_aspect_ratio=decrease,pad=720:720:(ow-iw)/2:(oh-ih)/2:color=white,fps=30" \
|
||||
-an -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \
|
||||
-g 1 -keyint_min 1 -sc_threshold 0 -movflags +faststart \
|
||||
public/media/cursor-portrait/poyraz-bottom-right.mp4
|
||||
```
|
||||
|
||||
Key decisions:
|
||||
|
||||
- `-an` removes audio completely.
|
||||
- `yuv420p` improves Safari and Chromium compatibility.
|
||||
- `faststart` moves MP4 metadata to the beginning.
|
||||
- `-g 1` makes every frame independently seekable.
|
||||
- `scale + pad` preserves proportions on a square white surface.
|
||||
|
||||
### Media optimization agent prompt
|
||||
|
||||
```prompt
|
||||
Prepare [[INPUT_VIDEO_PATH]] as a web motion-control plate that will be scrubbed
|
||||
forward and backward from pointer movement. Never overwrite the source file.
|
||||
|
||||
Outputs:
|
||||
- Video: [[VIDEO_PATH]]
|
||||
- Poster: [[POSTER_PATH]]
|
||||
- Exact duration: 4.00 seconds
|
||||
- Starting hold: 0.00–0.25
|
||||
- Active motion: 0.25–3.75
|
||||
- Final hold: 3.75–4.00
|
||||
- Resolution: 720×720
|
||||
- Frame rate: 30 FPS
|
||||
- Codec: H.264 MP4, libx264, yuv420p
|
||||
- Settings: preset slow, CRF 20, faststart, no audio
|
||||
- Every frame, or at most every second frame, must be a keyframe
|
||||
|
||||
Do not distort the aspect ratio. Use #FFFFFF padding when needed. Do not crop
|
||||
hair, face, ears, shoulders, or clothing. Verify duration, resolution, FPS,
|
||||
codec, and file size. Visually inspect the first, middle, and final frames.
|
||||
Do not modify unrelated project files.
|
||||
```
|
||||
|
||||
## 5. Mapping pointer Y to video time
|
||||
|
||||
Pointer Y is `0` at the viewport top and `window.innerHeight` at the bottom. Invert and clamp it to `0–1`, then map it to the active video range.
|
||||
|
||||
```ts
|
||||
const TOTAL_DURATION = 4;
|
||||
const ACTIVE_START = 0.25;
|
||||
const ACTIVE_END = 3.75;
|
||||
const DEFAULT_TIME = 2;
|
||||
const SMOOTHING = 0.12;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function mapPointerYToTime(pointerY: number, viewportHeight: number) {
|
||||
if (viewportHeight <= 0) return DEFAULT_TIME;
|
||||
|
||||
const progress = clamp(1 - pointerY / viewportHeight, 0, 1);
|
||||
return ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START);
|
||||
}
|
||||
```
|
||||
|
||||
| Pointer position | Progress | Video time | Gaze |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Viewport bottom | `0` | `0.25` | down-left |
|
||||
| Viewport middle | `0.5` | `2.00` | horizontal-left |
|
||||
| Viewport top | `1` | `3.75` | up-left |
|
||||
|
||||
`pointerX` never enters this formula, so horizontal pointer movement does not change the frame.
|
||||
|
||||
## 6. Smooth scrubbing in React
|
||||
|
||||
Keep high-frequency values in refs instead of updating React state for every pointer event. A single `requestAnimationFrame` loop damps the current value toward the target.
|
||||
|
||||
```ts
|
||||
const pointerYRef = useRef<number | null>(null);
|
||||
const targetTimeRef = useRef(DEFAULT_TIME);
|
||||
const currentTimeRef = useRef(DEFAULT_TIME);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
function animate() {
|
||||
const difference = targetTimeRef.current - currentTimeRef.current;
|
||||
currentTimeRef.current += difference * SMOOTHING;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video && Math.abs(video.currentTime - currentTimeRef.current) > 1 / 120) {
|
||||
video.currentTime = currentTimeRef.current;
|
||||
}
|
||||
|
||||
if (Math.abs(difference) > 0.002) {
|
||||
rafIdRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
|
||||
pointerYRef.current = event.clientY;
|
||||
targetTimeRef.current = mapPointerYToTime(
|
||||
event.clientY,
|
||||
window.innerHeight,
|
||||
);
|
||||
|
||||
if (rafIdRef.current === null) {
|
||||
rafIdRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Production code must also handle metadata readiness, optional video priming, tab visibility, and complete listener cleanup.
|
||||
|
||||
## 7. Ready-to-use coding-agent prompt
|
||||
|
||||
Replace the bracketed values and use this with a coding agent in an existing frontend project.
|
||||
|
||||
```prompt
|
||||
Add a reusable `PointerPortraitFollower` component to the existing [[FRAMEWORK]]
|
||||
project. It must stay at the bottom-right of the viewport and react only to the
|
||||
pointer's Y position. Styling system: [[STYLING_SYSTEM]].
|
||||
|
||||
Assets:
|
||||
- Video: [[VIDEO_PATH]]
|
||||
- Poster: [[POSTER_PATH]]
|
||||
|
||||
System constants:
|
||||
- TOTAL_DURATION = 4
|
||||
- ACTIVE_START = 0.25
|
||||
- ACTIVE_END = 3.75
|
||||
- DEFAULT_TIME = 2
|
||||
- SMOOTHING = 0.12
|
||||
|
||||
Behavior:
|
||||
- Keep the video paused; never autoplay it normally.
|
||||
- Use pointerY only. pointerX must never affect video timing.
|
||||
- progress = clamp(1 - pointerY / window.innerHeight, 0, 1)
|
||||
- targetTime = ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START)
|
||||
- Use a global pointermove listener without React state updates per event.
|
||||
- Store pointerY, targetTime, and currentTime in refs.
|
||||
- Apply damping in one requestAnimationFrame loop.
|
||||
- Limit seeks to about 30–60 Hz and skip tiny time differences.
|
||||
- Return smoothly to the neutral 2.00-second pose when the pointer leaves the
|
||||
window or the window loses focus.
|
||||
|
||||
Video element:
|
||||
- muted, playsInline, preload="auto", no controls, no autoplay
|
||||
- seek to 2.00 after loadedmetadata
|
||||
- prime muted playback briefly on the first real pointer move only if required
|
||||
- show the poster instead of a broken media icon after an asset error
|
||||
|
||||
Placement:
|
||||
- position: fixed; right: [[RIGHT_OFFSET]]; bottom: [[BOTTOM_OFFSET]]
|
||||
- width: [[DESKTOP_WIDTH]]; aspect-ratio: 1 / 1; z-index: [[Z_INDEX]]
|
||||
- object-fit: contain; background: [[BACKGROUND_COLOR]]
|
||||
- pointer-events: none; user-select: none; aria-hidden: true
|
||||
- no border, radius, shadow, or horizontal mirroring
|
||||
|
||||
Responsive and lifecycle:
|
||||
- disable animation on pointer: coarse and narrow viewports
|
||||
- never interpret touch as mouse tracking
|
||||
- honor prefers-reduced-motion
|
||||
- do not block CTA, link, or menu interaction
|
||||
- never access window/document during SSR
|
||||
- stop RAF and seeking while the tab is hidden
|
||||
- clean pointermove, pointerleave, blur, resize, visibilitychange, and RAF on
|
||||
unmount; never start multiple RAF loops
|
||||
|
||||
Separate mapping and clamp into pure typed helpers. Add boundary tests when a
|
||||
test setup exists. Do not add a heavy animation dependency. Run build,
|
||||
typecheck, lint, and existing tests after implementation.
|
||||
```
|
||||
|
||||
Values from this implementation:
|
||||
|
||||
```text
|
||||
[[FRAMEWORK]] = Next.js App Router, React, TypeScript
|
||||
[[STYLING_SYSTEM]] = Tailwind CSS and Poyraz UI
|
||||
[[VIDEO_PATH]] = /media/cursor-portrait/poyraz-bottom-right.mp4
|
||||
[[POSTER_PATH]] = /media/cursor-portrait/poyraz-bottom-right-poster.webp
|
||||
[[RIGHT_OFFSET]] = 24px
|
||||
[[BOTTOM_OFFSET]] = 0px
|
||||
[[DESKTOP_WIDTH]] = clamp(110px, 11vw, 170px)
|
||||
[[Z_INDEX]] = 40
|
||||
[[BACKGROUND_COLOR]] = #FFFFFF
|
||||
```
|
||||
|
||||
## 8. Mobile, accessibility, and fallback
|
||||
|
||||
This effect is meaningful on desktop with a mouse. Treating touch movement as pointer tracking hurts usability and wastes decoding work.
|
||||
|
||||
My choices:
|
||||
|
||||
- Do not render the component on `pointer: coarse` devices.
|
||||
- Hide it completely below `840px`.
|
||||
- Honor `prefers-reduced-motion`.
|
||||
- Hide the white-background asset in dark mode.
|
||||
- Keep it decorative with `pointer-events: none` and `aria-hidden="true"`.
|
||||
- Show the poster if video loading fails.
|
||||
|
||||
## 9. Quality checklist
|
||||
|
||||
### Video
|
||||
|
||||
- Is it the same person in the first, middle, and final frames?
|
||||
- Does the head stay turned about 60 degrees left?
|
||||
- Are the poses down-left, horizontal-left, and up-left in the correct order?
|
||||
- Do hair, ears, jaw, and facial features remain stable?
|
||||
- Do shoulders and clothing stay still?
|
||||
- Does the camera, light, or white background flicker?
|
||||
- Does the motion remain natural when scrubbed backward?
|
||||
|
||||
### Web
|
||||
|
||||
- Does the video remain paused before pointer input?
|
||||
- Does it scrub in the correct direction on vertical movement?
|
||||
- Does horizontal-only movement leave the frame unchanged?
|
||||
- Is there a seek queue or visible lag during fast movement?
|
||||
- Does the portrait return to neutral after leaving the window?
|
||||
- Are links and CTA controls still clickable?
|
||||
- Is animation disabled on mobile and reduced-motion?
|
||||
- Does the poster appear after a video error?
|
||||
- Are listeners and RAF cleaned up after navigation?
|
||||
|
||||
## Adapt it to your project
|
||||
|
||||
Five steps are enough to reuse the system:
|
||||
|
||||
1. Replace every `[[...]]` variable for your character.
|
||||
2. Produce a consistent 1:1 master frame on a flat background.
|
||||
3. Describe only one intended motion axis in the MiniMax H3 prompt.
|
||||
4. Map that same axis to the active video range.
|
||||
5. Connect the optimized video and poster to the component.
|
||||
|
||||
The main rule is simple: do not invent motion in code that does not exist in the generated video. Treating the AI output as a controlled motion plate makes the effect more natural, deterministic, and testable.
|
||||
|
||||
## 10. Master-frame prompt for avatars
|
||||
|
||||
When the source is a 2D, 3D, or stylized avatar, prevent the model from reinterpreting its design language. This prompt locks the original medium and character identity.
|
||||
|
||||
```prompt
|
||||
Use the uploaded avatar as the strict character-design reference. Recreate the
|
||||
exact same character in a clean, production-ready master frame for a
|
||||
mouse-following website animation.
|
||||
|
||||
Preserve exactly:
|
||||
- the character's face design, head shape, hairstyle, colors, outfit,
|
||||
accessories, proportions, material style, line style, shading language,
|
||||
and overall visual identity;
|
||||
- the original medium and aesthetic: [[AVATAR_STYLE]];
|
||||
- all intentional asymmetries and recognizable features.
|
||||
|
||||
Do not turn a 2D avatar into 3D, do not turn a stylized avatar into a real
|
||||
person, and do not redesign or improve the character.
|
||||
|
||||
Composition:
|
||||
- [[ASPECT_RATIO]] frame.
|
||||
- Medium close-up from [[CROP_POINT]] upward.
|
||||
- Center the character, leaving enough space for the head to rotate up to
|
||||
[[MAX_HEAD_ROTATION]] degrees toward [[TURN_DIRECTION]].
|
||||
- Shoulders remain stable and mostly facing forward.
|
||||
- Neutral starting pose, only [[STARTING_HEAD_ANGLE]] degrees toward
|
||||
[[STARTING_DIRECTION]].
|
||||
- Expression: [[EXPRESSION]].
|
||||
|
||||
Background:
|
||||
- Completely flat, seamless [[BACKGROUND_COLOR]].
|
||||
- No texture, gradient, cast shadow, props, text, logo, border, scenery, or UI.
|
||||
|
||||
Continuity requirements:
|
||||
- Clean silhouette and stable edges.
|
||||
- Consistent eyes and facial features according to the reference design.
|
||||
- No additional accessories or design changes.
|
||||
- Produce one character and one clean master frame only.
|
||||
```
|
||||
|
||||
## 11. Motion prompts for every placement
|
||||
|
||||
The live example in this guide maps vertical pointer movement to a vertical gaze. The alternatives below produce frontal-to-profile clips driven by horizontal pointer movement. Reverse the turn for left-side placements so that the subject looks into the page instead of away from it.
|
||||
|
||||
### Bottom-right: portrait turning left
|
||||
|
||||
```prompt
|
||||
Animate the uploaded master frame into a precise motion-control plate for an
|
||||
interactive website portrait. Preserve the exact identity, face, hairstyle,
|
||||
outfit, body proportions, lighting, colors, framing, and background.
|
||||
|
||||
Output:
|
||||
- Duration: exactly 4.0 seconds.
|
||||
- Aspect ratio: [[ASPECT_RATIO]].
|
||||
- One continuous shot with a locked, eye-level camera.
|
||||
- No zoom, crop change, pan, tilt, dolly, camera shake, speech, or audio motion.
|
||||
|
||||
The person will appear in the bottom-right corner. Website content and the
|
||||
pointer will usually be to the person's left, so the motion must progress from
|
||||
an almost frontal pose to a clear screen-left profile.
|
||||
|
||||
Timeline:
|
||||
- 0.00-0.25: hold the reference pose, only [[STARTING_HEAD_ANGLE]] degrees left.
|
||||
- 0.25-3.75: rotate smoothly and linearly toward screen-left.
|
||||
- Reach approximately [[MAX_HEAD_ROTATION]] degrees in a clean left profile.
|
||||
- 3.75-4.00: hold the final pose perfectly still.
|
||||
|
||||
The eyes lead slightly. Only eyes, head, and neck move. Shoulders, torso, arms,
|
||||
clothing, scale, body position, and expression remain fixed. Keep the mouth
|
||||
closed. No talking, smiling, eyebrow movement, nodding, leaning, breathing
|
||||
motion, blinking during the turn, or secondary gestures.
|
||||
|
||||
Preserve identity and anatomy in every frame. No morphing, face drift, hair or
|
||||
ear deformation, lighting change, clothing change, or background flicker.
|
||||
Keep a perfectly flat [[BACKGROUND_COLOR]] background with no gradient, shadow,
|
||||
texture, object, text, or logo.
|
||||
|
||||
This is a deterministic website animation plate intended to be paused and
|
||||
scrubbed frame by frame, not a cinematic video.
|
||||
```
|
||||
|
||||
Start with `[[MAX_HEAD_ROTATION]] = 85-90` and `[[STARTING_HEAD_ANGLE]] = 5-10` for a corner portrait.
|
||||
|
||||
### Bottom-left: portrait turning right
|
||||
|
||||
```prompt
|
||||
Animate the uploaded master frame into a precise motion-control plate for an
|
||||
interactive website portrait. Preserve the exact identity, face, hairstyle,
|
||||
outfit, proportions, lighting, framing, and background.
|
||||
|
||||
The person will appear in the bottom-left corner, while most content and pointer
|
||||
movement will be to the person's right.
|
||||
|
||||
Create exactly 4.0 seconds of one continuous, locked-off motion:
|
||||
- Start almost facing the camera, only [[STARTING_HEAD_ANGLE]] degrees right.
|
||||
- Hold the starting pose from 0.00 to 0.25 seconds.
|
||||
- From 0.25 to 3.75 seconds, rotate smoothly and linearly toward screen-right.
|
||||
- End at approximately [[MAX_HEAD_ROTATION]] degrees in a clean right profile.
|
||||
- Hold that final pose from 3.75 to 4.00 seconds.
|
||||
|
||||
Only the eyes, head, and neck move. The eyes lead slightly and stay focused
|
||||
toward screen-right. Shoulders, torso, arms, clothing, head scale, and body
|
||||
position remain fixed. The camera is completely locked.
|
||||
|
||||
Keep the mouth closed and [[EXPRESSION]] unchanged. No speech, smile, lip or
|
||||
eyebrow motion, nodding, leaning, blinking during the turn, breathing motion,
|
||||
or gestures. No identity drift, morphing, hair change, warped profile, ear
|
||||
deformation, lighting change, clothing change, or background flicker.
|
||||
|
||||
The background must remain perfectly flat [[BACKGROUND_COLOR]], without
|
||||
shadows, gradients, props, text, logos, textures, or color variation. This is a
|
||||
frame-scrubbable website plate, not a cinematic video.
|
||||
```
|
||||
|
||||
### Hero-right: looking at the headline and CTA on the left
|
||||
|
||||
```prompt
|
||||
Animate the uploaded identity-locked master frame for a website hero section.
|
||||
The subject will be positioned on the right side; headline, copy, CTA buttons,
|
||||
and pointer will be primarily on the left.
|
||||
|
||||
Create an exact 4.0-second locked-off motion-control clip. Start almost facing
|
||||
the viewer at [[STARTING_HEAD_ANGLE]] degrees left and hold from 0.00 to 0.25.
|
||||
From 0.25 to 3.75, smoothly rotate the eyes and head toward screen-left, ending
|
||||
at [[MAX_HEAD_ROTATION]] degrees. Hold the final pose from 3.75 to 4.00.
|
||||
|
||||
The final pose must feel like the subject is looking at the hero headline and
|
||||
CTA, not outside the page. Eyes lead slightly; the head follows in one slow,
|
||||
continuous, linear movement.
|
||||
|
||||
Only eyes, head, and neck move. Keep shoulders, torso, arms, clothing, position,
|
||||
scale, expression, and silhouette fixed. Mouth closed. No talking, smiling,
|
||||
blinking during the turn, nodding, leaning, gestures, body sway, or breathing.
|
||||
|
||||
Preserve the exact person or avatar design. No face drift, morphing, hair or
|
||||
outfit changes, lighting shifts, framing changes, or warped profile. Use a fixed
|
||||
eye-level camera and a perfectly flat [[BACKGROUND_COLOR]] background.
|
||||
Aspect ratio: [[ASPECT_RATIO]]. The result must be frame-scrubbable.
|
||||
```
|
||||
|
||||
For hero layouts, `[[MAX_HEAD_ROTATION]] = 65-75` usually looks more natural.
|
||||
|
||||
### Hero-left: looking at the headline and CTA on the right
|
||||
|
||||
```prompt
|
||||
Animate the uploaded identity-locked master frame for a website hero section.
|
||||
The subject will be positioned on the left side; headline, copy, CTA buttons,
|
||||
and pointer will be primarily on the right.
|
||||
|
||||
Create an exact 4.0-second locked-off motion-control clip. Start almost facing
|
||||
the viewer at [[STARTING_HEAD_ANGLE]] degrees right and hold from 0.00 to 0.25.
|
||||
From 0.25 to 3.75, smoothly rotate the eyes and head toward screen-right, ending
|
||||
at [[MAX_HEAD_ROTATION]] degrees. Hold the final pose from 3.75 to 4.00.
|
||||
|
||||
The final pose must feel like the subject is looking at the hero headline and
|
||||
CTA, not outside the page. Eyes lead slightly; the head follows in one slow,
|
||||
continuous, linear movement.
|
||||
|
||||
Only eyes, head, and neck move. Keep shoulders, torso, arms, clothing, position,
|
||||
scale, expression, and silhouette fixed. Mouth closed. No talking, smiling,
|
||||
blinking during the turn, nodding, leaning, gestures, body sway, or breathing.
|
||||
|
||||
Preserve the exact person or avatar design. No identity drift, morphing, hair or
|
||||
outfit changes, lighting shifts, framing changes, or warped profile. Use a fixed
|
||||
eye-level camera and a perfectly flat [[BACKGROUND_COLOR]] background.
|
||||
Aspect ratio: [[ASPECT_RATIO]]. The result must be frame-scrubbable.
|
||||
```
|
||||
|
||||
### Hero-center: complete left-to-right scan
|
||||
|
||||
```prompt
|
||||
Animate the uploaded identity-locked master frame into a symmetrical
|
||||
left-to-right head-turn calibration clip for an interactive centered hero.
|
||||
|
||||
Output one continuous 4.0-second shot in [[ASPECT_RATIO]] with a locked,
|
||||
eye-level camera. Preserve identity or avatar design, outfit, expression,
|
||||
lighting, framing, scale, and [[BACKGROUND_COLOR]] background.
|
||||
|
||||
Timeline:
|
||||
- 0.00-0.25: hold approximately [[LEFT_ANGLE]] degrees toward screen-left.
|
||||
- 0.25-3.75: perform the complete symmetrical left-to-right rotation.
|
||||
- Reach the exact front-facing pose at 50% of the active motion interval.
|
||||
- Continue at the same speed to [[RIGHT_ANGLE]] degrees toward screen-right.
|
||||
- 3.75-4.00: hold the final right-facing pose.
|
||||
- Keep path, speed, scale, and head height symmetrical on both sides.
|
||||
|
||||
The eyes lead only slightly. Only eyes, head, and neck move. Shoulders, torso,
|
||||
arms, clothing, body position, scale, and expression remain fixed. Mouth closed.
|
||||
No speech, smile, blink during movement, eyebrow motion, nod, lean, gesture,
|
||||
body sway, or breathing motion.
|
||||
|
||||
No identity drift, morphing, hairstyle change, ear deformation, warped profile,
|
||||
lighting shift, background flicker, camera movement, zoom, crop, or reframing.
|
||||
The background remains perfectly uniform [[BACKGROUND_COLOR]]. This must stay
|
||||
clean when paused and scrubbed in either direction.
|
||||
```
|
||||
|
||||
Use `[[LEFT_ANGLE]] = 75` and `[[RIGHT_ANGLE]] = 75` as a symmetric starting point.
|
||||
|
||||
## 12. Generic repair prompt
|
||||
|
||||
If the camera, shoulders, or mouth move, or if the profile loses identity, describe the defect precisely in `[[OBSERVED_PROBLEMS]]`.
|
||||
|
||||
```prompt
|
||||
Regenerate this clip as a strict technical motion plate. The previous result is
|
||||
unusable because it contains: [[OBSERVED_PROBLEMS]].
|
||||
|
||||
Lock every property except the intended head rotation:
|
||||
- exact same identity and facial proportions in every frame;
|
||||
- fixed camera, crop, focal length, scale, head position, shoulders, torso,
|
||||
arms, outfit, expression, lighting, and background;
|
||||
- only the eyes, head, and neck may move;
|
||||
- one slow, linear rotation from [[STARTING_DIRECTION_AND_ANGLE]] to
|
||||
[[ENDING_DIRECTION_AND_ANGLE]];
|
||||
- closed and motionless mouth;
|
||||
- no speech, smile, blink during the turn, eyebrow movement, nod, lean, body
|
||||
sway, breathing, camera motion, zoom, parallax, lighting shift, background
|
||||
flicker, face morphing, hair change, ear deformation, or new objects;
|
||||
- perfectly uniform [[BACKGROUND_COLOR]] background;
|
||||
- preserve the reference identity exactly, especially in the final profile.
|
||||
|
||||
This is a frame-scrubbing website asset, so every intermediate frame must be
|
||||
anatomically coherent and usable as a still image.
|
||||
```
|
||||
|
||||
## 13. Coding-agent prompt for horizontal variants
|
||||
|
||||
This prompt builds one reusable component for bottom-right, bottom-left, and hero placements. It uses `pointerX`, so treat it as an alternative to the vertical `pointerY` implementation earlier in this guide.
|
||||
|
||||
```prompt
|
||||
Add a reusable mouse-following video portrait component to the existing
|
||||
[[FRAMEWORK]] project. Styling system: [[STYLING_SYSTEM]]. Inspect the project's
|
||||
structure, responsive rules, dependencies, and code conventions first.
|
||||
|
||||
Assets:
|
||||
- Video: [[VIDEO_PATH]]
|
||||
- Poster: [[POSTER_PATH]]
|
||||
- Active motion: 0.25-3.75 seconds
|
||||
- Video motion: [[VIDEO_MOTION_DESCRIPTION]]
|
||||
- Placement: [[PLACEMENT]]
|
||||
|
||||
Behavior:
|
||||
- Keep the video muted, playsInline, preload auto, paused, and without autoplay.
|
||||
- Listen to global pointermove and measure the portrait anchor when needed.
|
||||
- Map pointer position to 0-1 targetProgress, then to currentTime 0.25-3.75.
|
||||
- Use RAF with lerp/damping. Do not update React state per pointer event.
|
||||
- Limit seeks to 30-60 Hz and skip negligible time differences.
|
||||
|
||||
Direction mapping:
|
||||
- For bottom-right or hero-right clips turning left: progress 0 near the
|
||||
portrait and progress 1 as the pointer moves farther left.
|
||||
- For bottom-left or hero-left clips turning right: progress 0 near the
|
||||
portrait and progress 1 as the pointer moves farther right.
|
||||
- For a hero-center clip scanning left-to-right, use pointerX / viewportWidth.
|
||||
- Clamp progress to 0-1 and prevent anatomically invalid reverse turns.
|
||||
|
||||
Typed API:
|
||||
- src, poster
|
||||
- placement: bottom-right | bottom-left | hero-right | hero-left | hero-center
|
||||
- defaultProgress, smoothing, desktopWidth, mobileWidth
|
||||
- offsetX, offsetY, zIndex, className, decorative, invertProgress
|
||||
|
||||
Layout and lifecycle:
|
||||
- Use fixed positioning for bottom-* and absolute positioning inside the hero
|
||||
for hero-* variants.
|
||||
- Use object-fit contain, a reserved aspect-ratio, and [[BACKGROUND_COLOR]].
|
||||
- If decorative, use pointer-events none, user-select none, draggable false,
|
||||
and aria-hidden true. Do not cover CTA controls or copy.
|
||||
- Run client-side. Seek to [[DEFAULT_PROGRESS]] after loadedmetadata.
|
||||
- If decoding needs it, prime muted playback on first real interaction and
|
||||
immediately pause.
|
||||
- Do not read layout every frame. Re-measure on resize/scroll at low cost.
|
||||
- Stop RAF/seeking in hidden tabs and clean every listener and RAF on unmount.
|
||||
- Disable tracking for coarse pointers and reduced motion; use
|
||||
[[MOBILE_BEHAVIOR]]. Show the poster after video errors.
|
||||
|
||||
Use named constants TOTAL_DURATION=4, ACTIVE_START=0.25, ACTIVE_END=3.75.
|
||||
Do not add a heavy animation library. Add complete TypeScript types and focused
|
||||
mapping/clamp tests without refactoring unrelated files.
|
||||
|
||||
Report changed files, direction formula, build/typecheck/lint/test results, and
|
||||
a four-item manual test checklist.
|
||||
```
|
||||
|
||||
### Add only one new placement
|
||||
|
||||
```prompt
|
||||
Do not break the behavior or public API of `CursorFollowerPortrait`. Add only a
|
||||
new [[NEW_PLACEMENT]] variant.
|
||||
|
||||
Asset:
|
||||
- Video: [[NEW_VIDEO_PATH]]
|
||||
- Poster: [[NEW_POSTER_PATH]]
|
||||
- Active motion: 0.25-3.75 seconds
|
||||
- Motion: [[NEW_VIDEO_MOTION_DESCRIPTION]]
|
||||
|
||||
Placement and mapping:
|
||||
- Placement: [[NEW_PLACEMENT]]
|
||||
- Offset: [[HORIZONTAL_OFFSET]] horizontal, [[VERTICAL_OFFSET]] vertical
|
||||
- Width: [[DESKTOP_WIDTH]] / mobile [[MOBILE_WIDTH]]
|
||||
- Anatomical direction rule: [[DIRECTION_MAPPING_RULE]]
|
||||
|
||||
Do not change existing variants. Add a working usage example, run build,
|
||||
typecheck, and lint, then report only changed files and verification results.
|
||||
```
|
||||
|
||||
### Debugging prompt
|
||||
|
||||
```prompt
|
||||
`CursorFollowerPortrait` has this problem: [[BUG_DESCRIPTION]].
|
||||
|
||||
Reproduce it first and identify the root cause with evidence. Check:
|
||||
- assigning currentTime before metadata loads;
|
||||
- slow seeking caused by codec or keyframe distance;
|
||||
- incorrect progress direction or invertProgress;
|
||||
- React renders on every pointer event;
|
||||
- duplicate RAF loops or event listeners;
|
||||
- getBoundingClientRect layout thrashing on every frame;
|
||||
- Safari/iOS video priming behavior;
|
||||
- asset path, CORS, preload, and poster fallback;
|
||||
- incorrect reduced-motion or coarse-pointer detection;
|
||||
- fixed/absolute containers and stacking contexts.
|
||||
|
||||
Do not refactor randomly before explaining the root cause. Apply the smallest
|
||||
safe fix, preserve the public API, and report build/typecheck/lint/test results.
|
||||
```
|
||||
|
||||
## 14. True two-axis tracking with a 3x3 grid
|
||||
|
||||
A single video can reliably follow only the axis it contains. For horizontal and vertical gaze, generate nine aligned poses from one master frame. For a real person, start with yaw values of `-35° / 0° / +35°` and pitch values of `-18° / 0° / +18°`.
|
||||
|
||||
| Pose | Yaw | Pitch |
|
||||
| --- | ---: | ---: |
|
||||
| Top-left | `[[YAW_LEFT]]` | `[[PITCH_UP]]` |
|
||||
| Top-center | `0` | `[[PITCH_UP]]` |
|
||||
| Top-right | `[[YAW_RIGHT]]` | `[[PITCH_UP]]` |
|
||||
| Middle-left | `[[YAW_LEFT]]` | `0` |
|
||||
| Center | `0` | `0` |
|
||||
| Middle-right | `[[YAW_RIGHT]]` | `0` |
|
||||
| Bottom-left | `[[YAW_LEFT]]` | `[[PITCH_DOWN]]` |
|
||||
| Bottom-center | `0` | `[[PITCH_DOWN]]` |
|
||||
| Bottom-right | `[[YAW_RIGHT]]` | `[[PITCH_DOWN]]` |
|
||||
|
||||
### Nine-direction image prompt
|
||||
|
||||
```prompt
|
||||
Using the uploaded identity-locked master frame, create one exact directional
|
||||
calibration pose for a 3x3 mouse-tracking portrait system.
|
||||
|
||||
Preserve the exact same identity, face, hairstyle, outfit, body, framing,
|
||||
camera, focal length, lighting, scale, background, crop, and expression. This
|
||||
image must align pixel-for-pixel as closely as possible with all other poses.
|
||||
|
||||
Change only:
|
||||
- head yaw: [[TARGET_YAW]] degrees;
|
||||
- head pitch: [[TARGET_PITCH]] degrees;
|
||||
- eye gaze: [[GAZE_DIRECTION]], aligned naturally with the head direction.
|
||||
|
||||
Keep shoulders, torso, arms, clothing, body position, head center, and head
|
||||
scale fixed. Mouth closed. No smile, speech, blink, eyebrow movement, body
|
||||
turn, lean, camera motion, crop change, zoom, or lighting change.
|
||||
|
||||
Maintain anatomically correct neck, ears, eyes, jaw, and facial profile. No
|
||||
morphing or identity drift. Use a perfectly flat [[BACKGROUND_COLOR]] identical
|
||||
to the master frame. Output one image only at [[OUTPUT_DIMENSIONS]].
|
||||
```
|
||||
|
||||
### Coding-agent prompt for the nine-direction system
|
||||
|
||||
```prompt
|
||||
Add a `DirectionalPortraitGrid` component with true two-axis pointer tracking
|
||||
to the existing [[FRAMEWORK]] project.
|
||||
|
||||
Nine equal-size, pixel-aligned assets:
|
||||
- top-left: [[TOP_LEFT_PATH]]
|
||||
- top-center: [[TOP_CENTER_PATH]]
|
||||
- top-right: [[TOP_RIGHT_PATH]]
|
||||
- middle-left: [[MIDDLE_LEFT_PATH]]
|
||||
- center: [[CENTER_PATH]]
|
||||
- middle-right: [[MIDDLE_RIGHT_PATH]]
|
||||
- bottom-left: [[BOTTOM_LEFT_PATH]]
|
||||
- bottom-center: [[BOTTOM_CENTER_PATH]]
|
||||
- bottom-right: [[BOTTOM_RIGHT_PATH]]
|
||||
|
||||
Normalize the pointer relative to the portrait center and clamp both axes to
|
||||
-1..1. Do not hard-switch to the nearest image. Find the surrounding four grid
|
||||
cells, calculate bilinear interpolation weights, and blend those four aligned
|
||||
images with opacity.
|
||||
|
||||
Use RAF and damping without React renders per pointer event. Preload assets
|
||||
without blocking the page's LCP. Show the center image for reduced motion,
|
||||
coarse pointers, or loading failures. If decorative, use pointer-events none
|
||||
and aria-hidden true.
|
||||
|
||||
Typed props: sources, placement, desktopWidth, mobileWidth, smoothing,
|
||||
maxTrackingDistance, offsetX, offsetY, className, decorative. Extract grid math
|
||||
into pure functions and test corners, center, and intermediate values. Do not
|
||||
add a heavy animation library. Add a usage example and report
|
||||
build/typecheck/lint/test results.
|
||||
```
|
||||
|
||||
## 15. Reels planning prompt
|
||||
|
||||
For a 55-second walkthrough, use this sequence: `0-3s` result, `3-7s` hook, `7-16s` master frame, `16-26s` motion video, `26-42s` coding, `42-51s` before/after and variant, `51-58s` CTA.
|
||||
|
||||
```prompt
|
||||
I am a [[CREATOR_PROFILE]] software content creator. Plan an Instagram Reel for
|
||||
this interactive website effect:
|
||||
|
||||
Effect: [[EFFECT_DESCRIPTION]]
|
||||
Character: [[REAL_PERSON_OR_AVATAR]]
|
||||
Placement: [[PLACEMENT]]
|
||||
Generation tool: [[GENERATION_TOOL]]
|
||||
Coding agent: [[CODING_AGENT]]
|
||||
Audience: [[TARGET_AUDIENCE]]
|
||||
Target duration: 55 seconds
|
||||
Tone: [[TONE]]
|
||||
|
||||
Use this structure:
|
||||
1. Show the result in the first two seconds as a visual hook.
|
||||
2. A spoken hook of no more than ten words.
|
||||
3. A concrete promise for what the viewer will build.
|
||||
4. The reference-to-master-frame step.
|
||||
5. Only the critical lines of the placement-specific motion prompt.
|
||||
6. Only the critical technical logic from the coding prompt.
|
||||
7. One beginner-friendly sentence explaining pointer-to-currentTime mapping.
|
||||
8. A before/after scene.
|
||||
9. An open loop into the next placement variant.
|
||||
10. A short, natural CTA.
|
||||
|
||||
For every segment, provide its time range, spoken line, screen recording,
|
||||
large on-screen text, and editing transition. Avoid exaggerated marketing,
|
||||
unnecessary jargon, long intros, claims that AI did everything, or unrealistic
|
||||
time promises. Highlight prompt fragments instead of displaying entire prompts.
|
||||
Keep the total spoken script under 120 words.
|
||||
```
|
||||
|
||||
This library lets you produce bottom-right, bottom-left, hero-right, hero-left, hero-center, and true 3x3 tracking assets from the same master design. Name each asset together with its motion direction, placement formula, and fallback to prevent direction mistakes as the system grows.
|
||||
@@ -0,0 +1,884 @@
|
||||
---
|
||||
title: "Fareyi Takip Eden AI Avatar Nasıl Yapılır?"
|
||||
slug: "poyraz-cursor-portrait"
|
||||
excerpt: "Wiro AI üzerinde MiniMax H3 ile ürettiğim 1:1 avatar videosunu, promptlardan FFmpeg optimizasyonuna ve React entegrasyonuna kadar adım adım oluşturun."
|
||||
coverImage: "/animation-sources/poyraz-cursor-portrait/avatar-mouse-follow.webp"
|
||||
platform: "Web"
|
||||
tools:
|
||||
- "Wiro AI"
|
||||
- "MiniMax H3"
|
||||
- "FFmpeg"
|
||||
- "React"
|
||||
date: "2026-08-29"
|
||||
author: "Poyraz Avsever"
|
||||
lang: "tr"
|
||||
---
|
||||
|
||||
Bu efektte sağ alttaki portre normal bir video gibi oynatılmıyor. Video duraklatılmış halde tutuluyor; farenin ekrandaki dikey konumu videonun zaman çizelgesine bağlanıyor. Fare aşağıdayken portre aşağı-sola, ortadayken yatay-sola, yukarıdayken yukarı-sola bakıyor.
|
||||
|
||||
Videoyu **Wiro AI** üzerinden **MiniMax H3** modeliyle, **1:1 kare formatta** ürettim. Sonrasında videoyu FFmpeg ile sık ileri-geri sarılmaya uygun hale getirip React içinde `currentTime` üzerinden kontrol ettim.
|
||||
|
||||
Bu sayfadaki promptları doğrudan kopyalayabilir ve kendi portreniz, avatarınız veya marka karakteriniz için uyarlayabilirsiniz.
|
||||
|
||||
## Promptlardaki `[[...]]` alanları nasıl kullanılır?
|
||||
|
||||
Promptlarda gördüğünüz çift köşeli parantezler doldurulması gereken değişken alanlardır. Örneğin `[[OUTFIT]]` ifadesini promptta bırakmak yerine `plain red polo shirt` gibi kendi değerinizi yazmalısınız.
|
||||
|
||||
| Değişken | Ne yazılmalı? | Bu projedeki değer |
|
||||
| --- | --- | --- |
|
||||
| `[[SUBJECT]]` | Kişi veya karakter tanımı | 20 yaşında erkek içerik üreticisi |
|
||||
| `[[OUTFIT]]` | Kıyafet | düz kırmızı polo tişört |
|
||||
| `[[BACKGROUND_COLOR]]` | Düz arka plan | saf beyaz, `#FFFFFF` |
|
||||
| `[[EXPRESSION]]` | Sabit yüz ifadesi | doğal ve nötr |
|
||||
| `[[ASPECT_RATIO]]` | Üretim oranı | `1:1` |
|
||||
| `[[HEAD_DIRECTION]]` | Başın sabit yatay yönü | yaklaşık 60 derece sola |
|
||||
| `[[VIDEO_PATH]]` | Web video yolu | `/media/cursor-portrait/poyraz-bottom-right.mp4` |
|
||||
| `[[POSTER_PATH]]` | Poster yolu | `/media/cursor-portrait/poyraz-bottom-right-poster.webp` |
|
||||
| `[[FRAMEWORK]]` | Kullanılan teknoloji | Next.js, React, TypeScript |
|
||||
| `[[STYLING_SYSTEM]]` | Stil sistemi | Tailwind CSS |
|
||||
|
||||
Bir promptu kullanmadan önce içindeki tüm `[[...]]` alanlarını aratın. Projeniz için karşılığı olmayan bir değişken kalmamalı.
|
||||
|
||||
## Sistem nasıl çalışıyor?
|
||||
|
||||
Tek bir videoyu gerçek zamanlı kontrol etmenin en stabil yolu videoyu sürekli oynatmak değil, onu kısa bir **hareket plakası** olarak kullanmaktır.
|
||||
|
||||
Bu uygulamadaki dört saniyelik zaman çizelgesi:
|
||||
|
||||
1. `0.00–0.25`: aşağı-sola bakış pozu sabit tutulur.
|
||||
2. `0.25–3.75`: bakış aşağıdan yukarıya doğru ilerler.
|
||||
3. Yaklaşık `2.00`: sola doğru nötr ve yatay bakış oluşur.
|
||||
4. `3.75–4.00`: yukarı-sola bakış pozu sabit tutulur.
|
||||
|
||||
Fare aşağı-yukarı hareket ettikçe video bu aktif aralıkta ileri veya geri sarılır. Yatay fare konumu bu sürümde kullanılmaz; böylece yapay zeka videosunda bulunmayan ikinci bir hareket ekseni uydurulmaz.
|
||||
|
||||
> Tek video yalnızca üretilmiş hareket ekseninde güvenilir sonuç verir. Gerçek yatay ve dikey takip gerekiyorsa farklı yönlere ait tutarlı karelerden oluşan 3×3 bir sistem daha doğru yaklaşımdır.
|
||||
|
||||
## Üretim akışı
|
||||
|
||||
1. Net, önden çekilmiş bir referans fotoğraf seçin.
|
||||
2. Kimliği, kıyafeti, ışığı ve beyaz arka planı sabitleyen 1:1 master kareyi hazırlayın.
|
||||
3. Master kareyi Wiro AI'a yükleyip MiniMax H3 ile hareket videosunu üretin.
|
||||
4. Kimlik kayması, kamera hareketi veya arka plan titreşimi varsa onarım promptuyla yeniden üretin.
|
||||
5. Videoyu 720×720 H.264 web asset'ine dönüştürün.
|
||||
6. Videonun `currentTime` değerini farenin Y konumuna bağlayın.
|
||||
7. Masaüstü, reduced-motion, koyu tema ve mobil davranışlarını ayrı ayrı test edin.
|
||||
|
||||
## 1. Master kare promptu
|
||||
|
||||
Referans fotoğrafı kullandığınız görsel üretim aracına yükleyin. Aşağıdaki promptta önce tüm `[[...]]` alanlarını değiştirin.
|
||||
|
||||
```prompt
|
||||
Use the uploaded image only as the identity reference for [[SUBJECT]].
|
||||
Create a new photorealistic, production-ready 1:1 studio portrait for an
|
||||
interactive website animation. Preserve the exact recognizable identity,
|
||||
facial proportions, skin tone, hairstyle, hairline, eyebrows, eye shape,
|
||||
nose, lips, jawline, age, and overall appearance.
|
||||
|
||||
Composition:
|
||||
- Square [[ASPECT_RATIO]] frame.
|
||||
- Medium close-up from [[CROP_POINT]] upward.
|
||||
- Keep the full head, hair, ears, neck, shoulders, and visible upper torso
|
||||
safely inside the frame.
|
||||
- Keep comfortable negative space around the hair and shoulders.
|
||||
- The shoulders remain stable and the head is turned approximately
|
||||
[[HEAD_DIRECTION]].
|
||||
- Expression: [[EXPRESSION]].
|
||||
- Outfit: [[OUTFIT]].
|
||||
|
||||
Background and light:
|
||||
- Perfectly flat, seamless [[BACKGROUND_COLOR]] background.
|
||||
- No gradient, texture, horizon line, furniture, props, text, watermark,
|
||||
logo, border, or visible cast shadow.
|
||||
- Soft, bright studio lighting with natural skin texture.
|
||||
- Keep hair, ears, face, shoulders, and clothing edges clean.
|
||||
|
||||
Continuity constraints:
|
||||
- Do not beautify, age, de-age, stylize, or reinterpret the person.
|
||||
- Do not change facial hair, outfit, accessories, body proportions, or light.
|
||||
- Do not crop the hair, ears, shoulders, or upper torso.
|
||||
- Generate one person and one clean master frame only.
|
||||
```
|
||||
|
||||
Bu uygulama için kullandığım değerler:
|
||||
|
||||
```text
|
||||
[[SUBJECT]] = a young male software creator
|
||||
[[ASPECT_RATIO]] = 1:1
|
||||
[[CROP_POINT]] = mid-torso
|
||||
[[HEAD_DIRECTION]] = 60 degrees toward screen-left
|
||||
[[EXPRESSION]] = calm, natural, neutral expression
|
||||
[[OUTFIT]] = plain red polo shirt
|
||||
[[BACKGROUND_COLOR]] = pure white (#FFFFFF)
|
||||
```
|
||||
|
||||
## 2. Wiro AI / MiniMax H3 video promptu
|
||||
|
||||
Master kareyi Wiro AI üzerinde MiniMax H3 modeline referans olarak verin. Bu promptun amacı sinematik bir sahne değil, kare kare durdurulup sarılabilecek teknik bir hareket üretmektir.
|
||||
|
||||
```prompt
|
||||
Animate the uploaded 1:1 master frame into a precise four-second motion-control
|
||||
plate for an interactive website portrait. Preserve the exact identity, face,
|
||||
hairstyle, red polo shirt, body proportions, lighting, colors, square framing,
|
||||
and pure white background from the reference image.
|
||||
|
||||
Output:
|
||||
- Duration: exactly 4.0 seconds.
|
||||
- Aspect ratio: 1:1.
|
||||
- One continuous shot with a completely locked, eye-level camera.
|
||||
- No zoom, crop change, pan, tilt, dolly, reframing, or camera shake.
|
||||
- No speech and no audio-dependent movement.
|
||||
|
||||
Head direction:
|
||||
- Keep the subject turned approximately 60 degrees toward screen-left for
|
||||
the entire video.
|
||||
- The horizontal head angle must not change.
|
||||
- Never turn toward the camera and never rotate into a full side profile.
|
||||
|
||||
Exact motion timeline:
|
||||
- 0.00–0.25 seconds: hold a clean down-left gaze and head-tilt pose.
|
||||
- 0.25–3.75 seconds: move smoothly and continuously from down-left to up-left.
|
||||
- At exactly 2.00 seconds: reach a neutral horizontal-left gaze.
|
||||
- 3.75–4.00 seconds: hold the final up-left pose perfectly still.
|
||||
|
||||
Movement rules:
|
||||
- Only the eyes and the minimum natural head/neck tilt required for the
|
||||
vertical gaze may move.
|
||||
- Shoulders, torso, arms, clothing, body position, head scale, and horizontal
|
||||
head angle remain fixed.
|
||||
- Keep the mouth closed and motionless.
|
||||
- No talking, smiling, eyebrow movement, nodding, leaning, body sway,
|
||||
breathing motion, or secondary gesture.
|
||||
- Movement must be slow, linear, anatomically coherent, and usable when
|
||||
scrubbed both forward and backward.
|
||||
|
||||
Continuity:
|
||||
- Preserve the same recognizable face in every frame.
|
||||
- Keep hair volume, hairline, ears, nose, jaw, skin texture, clothing folds,
|
||||
and lighting stable.
|
||||
- No face drift, morphing, warped anatomy, flicker, or changing expression.
|
||||
- Keep the background perfectly uniform pure white (#FFFFFF) in every frame.
|
||||
|
||||
This is not a cinematic scene. It is a deterministic frame-scrubbing asset
|
||||
for a website and every intermediate frame must work as a clean still image.
|
||||
```
|
||||
|
||||
MiniMax H3 çıktısını değerlendirirken yalnızca ilk ve son kareye bakmayın. Orta karede yüzün, kulağın, saç çizgisinin ve tişört kenarlarının bozulmadığını da kontrol edin.
|
||||
|
||||
## 3. Sorunlu videoyu yeniden üretme promptu
|
||||
|
||||
İlk üretimde yüz kayması veya kamera hareketi varsa problemi `[[OBSERVED_PROBLEMS]]` alanına açıkça yazın.
|
||||
|
||||
```prompt
|
||||
Regenerate this clip as a strict technical motion plate. The previous result
|
||||
is unusable because: [[OBSERVED_PROBLEMS]].
|
||||
|
||||
Lock every property except the intended vertical gaze and head-tilt movement:
|
||||
- preserve the exact identity and facial proportions in every frame;
|
||||
- keep the horizontal head angle fixed at approximately 60 degrees left;
|
||||
- fixed camera, crop, focal length, scale, head position, shoulders, torso,
|
||||
arms, outfit, expression, lighting, and background;
|
||||
- one slow linear movement from down-left to up-left;
|
||||
- neutral horizontal-left pose at exactly two seconds;
|
||||
- closed and motionless mouth;
|
||||
- no speech, smile, blink during movement, eyebrow motion, body sway,
|
||||
zoom, parallax, lighting shift, background flicker, face morphing,
|
||||
hair change, ear deformation, or new objects;
|
||||
- perfectly uniform pure white (#FFFFFF) background.
|
||||
|
||||
This clip will be paused and scrubbed frame by frame. Every intermediate frame
|
||||
must remain anatomically coherent and visually consistent with the reference.
|
||||
```
|
||||
|
||||
Örnek problem tanımı:
|
||||
|
||||
```text
|
||||
[[OBSERVED_PROBLEMS]] = the face changes near the final pose, the shoulders
|
||||
move with the head, and the white background flickers between frames
|
||||
```
|
||||
|
||||
## 4. Videoyu web için hazırlama
|
||||
|
||||
Yapay zeka videosu doğrudan tarayıcıya konabilir; ancak sık `currentTime` güncellemelerinde codec ve keyframe aralığı büyük fark yaratır. Ben çıktıyı 720×720, 30 FPS, sessiz H.264 ve her kare keyframe olacak şekilde hazırladım.
|
||||
|
||||
```bash
|
||||
ffmpeg -i INPUT.mp4 \
|
||||
-vf "scale=720:720:force_original_aspect_ratio=decrease,pad=720:720:(ow-iw)/2:(oh-ih)/2:color=white,fps=30" \
|
||||
-an -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \
|
||||
-g 1 -keyint_min 1 -sc_threshold 0 -movflags +faststart \
|
||||
public/media/cursor-portrait/poyraz-bottom-right.mp4
|
||||
```
|
||||
|
||||
Buradaki kritik tercihler:
|
||||
|
||||
- `-an`: sesi tamamen kaldırır.
|
||||
- `yuv420p`: Safari ve Chromium uyumluluğunu artırır.
|
||||
- `faststart`: MP4 metadata'sını dosyanın başına taşır.
|
||||
- `-g 1`: her kareyi keyframe yaparak sık seek işlemini hızlandırır.
|
||||
- `scale + pad`: görüntüyü esnetmeden 1:1 beyaz yüzeyde tutar.
|
||||
|
||||
### Medya optimizasyonu için coding-agent promptu
|
||||
|
||||
```prompt
|
||||
Projeye eklediğim [[INPUT_VIDEO_PATH]] videosunu fare konumuyla ileri ve geri
|
||||
sarılacak bir web hareket plakası olarak hazırla.
|
||||
|
||||
Kaynak dosyaya dokunma veya üzerine yazma.
|
||||
|
||||
Hedefler:
|
||||
- Video: [[VIDEO_PATH]]
|
||||
- Poster: [[POSTER_PATH]]
|
||||
- Tam süre: 4.00 saniye
|
||||
- Başlangıç pozu: 0.00–0.25
|
||||
- Aktif hareket: 0.25–3.75
|
||||
- Son poz: 3.75–4.00
|
||||
- Çözünürlük: 720×720
|
||||
- FPS: 30
|
||||
- Codec: H.264 MP4, libx264, yuv420p
|
||||
- Ayarlar: preset slow, CRF 20, faststart, ses yok
|
||||
- Sık currentTime değişimi için her kare veya en fazla iki karede bir keyframe
|
||||
|
||||
En-boy oranını bozma. Gerekirse #FFFFFF padding kullan. Saç, yüz, kulak,
|
||||
omuz veya kıyafeti kesme. İşlemden sonra süre, çözünürlük, FPS, codec ve dosya
|
||||
boyutunu doğrula; ilk, orta ve son kareyi görsel olarak kontrol et. Alakasız
|
||||
proje dosyalarına dokunma.
|
||||
```
|
||||
|
||||
## 5. Mouse Y değerini video zamanına eşleme
|
||||
|
||||
Farenin Y konumu ekranın üstünde `0`, altında `window.innerHeight` değerindedir. Önce bu değeri ters çevirip `0–1` aralığına sıkıştırıyorum, ardından videonun aktif zaman aralığına map ediyorum.
|
||||
|
||||
```ts
|
||||
const TOTAL_DURATION = 4;
|
||||
const ACTIVE_START = 0.25;
|
||||
const ACTIVE_END = 3.75;
|
||||
const DEFAULT_TIME = 2;
|
||||
const SMOOTHING = 0.12;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function mapPointerYToTime(pointerY: number, viewportHeight: number) {
|
||||
if (viewportHeight <= 0) return DEFAULT_TIME;
|
||||
|
||||
const progress = clamp(1 - pointerY / viewportHeight, 0, 1);
|
||||
return ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START);
|
||||
}
|
||||
```
|
||||
|
||||
Eşleme sonucu:
|
||||
|
||||
| Fare konumu | Progress | Video zamanı | Bakış |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Ekranın altı | `0` | `0.25` | aşağı-sola |
|
||||
| Ekranın ortası | `0.5` | `2.00` | yatay-sola |
|
||||
| Ekranın üstü | `1` | `3.75` | yukarı-sola |
|
||||
|
||||
`pointerX` bu hesaplamaya hiç girmez. Fare yalnızca sağa veya sola hareket ettiğinde video karesi değişmez.
|
||||
|
||||
## 6. Akıcı scrub için React yaklaşımı
|
||||
|
||||
Her pointer event'inde React state güncellemek yerine yüksek frekanslı değerleri ref içinde tutun. Tek bir `requestAnimationFrame` döngüsü mevcut zamanı hedef zamana yaklaştırsın.
|
||||
|
||||
```ts
|
||||
const pointerYRef = useRef<number | null>(null);
|
||||
const targetTimeRef = useRef(DEFAULT_TIME);
|
||||
const currentTimeRef = useRef(DEFAULT_TIME);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
function animate() {
|
||||
const difference = targetTimeRef.current - currentTimeRef.current;
|
||||
currentTimeRef.current += difference * SMOOTHING;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video && Math.abs(video.currentTime - currentTimeRef.current) > 1 / 120) {
|
||||
video.currentTime = currentTimeRef.current;
|
||||
}
|
||||
|
||||
if (Math.abs(difference) > 0.002) {
|
||||
rafIdRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
|
||||
pointerYRef.current = event.clientY;
|
||||
targetTimeRef.current = mapPointerYToTime(
|
||||
event.clientY,
|
||||
window.innerHeight,
|
||||
);
|
||||
|
||||
if (rafIdRef.current === null) {
|
||||
rafIdRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Üretim kodunda ayrıca metadata yüklenmesini, video priming ihtiyacını, sekme görünürlüğünü ve listener temizliğini yönetmek gerekir.
|
||||
|
||||
## 7. Component'i kodlatmak için hazır prompt
|
||||
|
||||
Aşağıdaki prompt Next.js, React veya benzer bir frontend projesinde coding agent ile kullanılabilir. Köşeli alanları kendi projenize göre doldurun.
|
||||
|
||||
```prompt
|
||||
Mevcut [[FRAMEWORK]] projesine, ekranın sağ altında duran ve farenin yalnızca
|
||||
Y konumunu takip eden tekrar kullanılabilir `PointerPortraitFollower` component'i
|
||||
ekle. Stil sistemi: [[STYLING_SYSTEM]].
|
||||
|
||||
Asset'ler:
|
||||
- Video: [[VIDEO_PATH]]
|
||||
- Poster: [[POSTER_PATH]]
|
||||
|
||||
Sistem sabitleri:
|
||||
- TOTAL_DURATION = 4
|
||||
- ACTIVE_START = 0.25
|
||||
- ACTIVE_END = 3.75
|
||||
- DEFAULT_TIME = 2
|
||||
- SMOOTHING = 0.12
|
||||
|
||||
Davranış:
|
||||
- Video normal şekilde oynatılmayacak; paused tutulacak.
|
||||
- Yalnızca pointerY kullan. pointerX zamanlamayı hiçbir şekilde etkilemesin.
|
||||
- progress = clamp(1 - pointerY / window.innerHeight, 0, 1)
|
||||
- targetTime = ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START)
|
||||
- Global pointermove listener kullan fakat pointer event başına React state
|
||||
güncelleme.
|
||||
- pointerY, targetTime ve currentTime değerlerini ref içinde tut.
|
||||
- Tek requestAnimationFrame döngüsünde damping uygula.
|
||||
- Seek işlemlerini yaklaşık 30–60 Hz ile sınırla ve çok küçük farklarda atla.
|
||||
- Fare pencere dışına çıktığında veya pencere odağı kaybolduğunda 2.00 saniyelik
|
||||
nötr poza yumuşakça dön.
|
||||
|
||||
Video:
|
||||
- muted, playsInline, preload="auto", controls yok, autoplay yok
|
||||
- loadedmetadata sonrasında 2.00 saniyeye getir
|
||||
- İlk gerçek pointer hareketinde gerekiyorsa muted olarak kısa prime et ve pause et
|
||||
- Asset hatasında kırık video ikonu yerine poster göster
|
||||
|
||||
Yerleşim:
|
||||
- position: fixed; right: [[RIGHT_OFFSET]]; bottom: [[BOTTOM_OFFSET]]
|
||||
- width: [[DESKTOP_WIDTH]]; aspect-ratio: 1 / 1; z-index: [[Z_INDEX]]
|
||||
- object-fit: contain; background: [[BACKGROUND_COLOR]]
|
||||
- pointer-events: none; user-select: none; aria-hidden: true
|
||||
- Border, radius, shadow veya yatay aynalama ekleme
|
||||
|
||||
Responsive:
|
||||
- pointer: coarse veya dar ekranda animasyonu kapat
|
||||
- Touch hareketlerini fare gibi yorumlama
|
||||
- prefers-reduced-motion durumunda poster göster veya component'i gizle
|
||||
- CTA, link ve menülerin tıklanmasını engelleme
|
||||
|
||||
Yaşam döngüsü:
|
||||
- SSR sırasında window/document kullanma
|
||||
- visibilitychange ile görünmeyen sekmede RAF ve seek'i durdur
|
||||
- pointermove, pointerleave, blur, resize, visibilitychange ve RAF temizliğini
|
||||
unmount sırasında eksiksiz yap
|
||||
- Aynı anda birden fazla RAF döngüsü başlatma
|
||||
|
||||
Mapping ve clamp işlemlerini saf TypeScript yardımcılarına ayır. Projede test
|
||||
altyapısı varsa alt, orta, üst ve clamp sınırları için test ekle. Ağır animasyon
|
||||
kütüphanesi ekleme. Build, typecheck, lint ve mevcut testleri çalıştır.
|
||||
```
|
||||
|
||||
Bu uygulamadaki örnek değişkenler:
|
||||
|
||||
```text
|
||||
[[FRAMEWORK]] = Next.js App Router, React, TypeScript
|
||||
[[STYLING_SYSTEM]] = Tailwind CSS and Poyraz UI
|
||||
[[VIDEO_PATH]] = /media/cursor-portrait/poyraz-bottom-right.mp4
|
||||
[[POSTER_PATH]] = /media/cursor-portrait/poyraz-bottom-right-poster.webp
|
||||
[[RIGHT_OFFSET]] = 24px
|
||||
[[BOTTOM_OFFSET]] = 0px
|
||||
[[DESKTOP_WIDTH]] = clamp(110px, 11vw, 170px)
|
||||
[[Z_INDEX]] = 40
|
||||
[[BACKGROUND_COLOR]] = #FFFFFF
|
||||
```
|
||||
|
||||
## 8. Mobil, erişilebilirlik ve fallback
|
||||
|
||||
Bu efekt masaüstünde fare ile anlam kazanıyor. Touch hareketlerini pointer takibi gibi yorumlamak sayfayı kullanmayı zorlaştırır ve gereksiz video decode maliyeti oluşturur.
|
||||
|
||||
Benim tercihlerim:
|
||||
|
||||
- `pointer: coarse` cihazlarda component'i render etmemek.
|
||||
- `840px` altındaki ekranlarda tamamen gizlemek.
|
||||
- `prefers-reduced-motion` tercihine saygı göstermek.
|
||||
- Koyu temada beyaz arka planlı asset'i gizlemek.
|
||||
- Portreyi `pointer-events: none` ve `aria-hidden="true"` ile dekoratif tutmak.
|
||||
- Video yüklenmezse poster göstermek.
|
||||
|
||||
## 9. Kalite kontrol listesi
|
||||
|
||||
### Video
|
||||
|
||||
- İlk, orta ve son karede aynı kişi görünüyor mu?
|
||||
- Baş video boyunca yaklaşık 60 derece sola dönük kalıyor mu?
|
||||
- İlk kare aşağı-sola, orta kare yatay-sola, son kare yukarı-sola mı bakıyor?
|
||||
- Saç, kulak, çene ve yüz orta karelerde bozuluyor mu?
|
||||
- Omuzlar veya tişört istemeden hareket ediyor mu?
|
||||
- Kamera, ışık veya beyaz arka plan titreşiyor mu?
|
||||
- Video tersine sarıldığında hareket doğal görünüyor mu?
|
||||
|
||||
### Web
|
||||
|
||||
- Video fare hareket etmeden kendi kendine oynuyor mu? Oynamamalı.
|
||||
- Fare yukarı ve aşağı giderken doğru yönde sarılıyor mu?
|
||||
- Yalnızca sağa-sola harekette video zamanı sabit kalıyor mu?
|
||||
- Hızlı harekette seek kuyruğu veya gecikme oluşuyor mu?
|
||||
- Fare pencere dışına çıktığında nötr poza dönüyor mu?
|
||||
- Portre linklerin ve CTA'ların tıklanmasını engelliyor mu?
|
||||
- Mobilde ve reduced-motion modunda animasyon kapanıyor mu?
|
||||
- Video yüklenmezse poster görünüyor mu?
|
||||
- Sayfa değişiminden sonra listener veya RAF ikiye katlanıyor mu?
|
||||
|
||||
## Kendi projenize uyarlayın
|
||||
|
||||
Bu sistemi farklı bir kişi, çizim veya marka maskotuna taşımak için beş şey yeterli:
|
||||
|
||||
1. `[[...]]` değişkenlerini karakterinize göre doldurun.
|
||||
2. 1:1 ve düz arka planlı tutarlı bir master kare üretin.
|
||||
3. MiniMax H3 video promptunda yalnızca istediğiniz hareket eksenini tarif edin.
|
||||
4. Aktif video aralığını farenin aynı eksenine map edin.
|
||||
5. Optimize video ve poster yollarını component'e bağlayın.
|
||||
|
||||
En kritik karar, videoda olmayan bir hareketi kod tarafında taklit etmeye çalışmamaktır. Yapay zeka videosunu kontrollü bir hareket plakası olarak tasarladığınızda efekt hem daha doğal hem de daha kolay test edilebilir hale gelir.
|
||||
|
||||
## 10. Avatar için master kare promptu
|
||||
|
||||
Gerçek fotoğraf yerine 2D, 3D veya stilize bir avatar kullanıyorsanız aracın karakteri yeniden yorumlamasını engellemeniz gerekir. Bu prompt, avatarın mevcut tasarım dilini kilitler.
|
||||
|
||||
```prompt
|
||||
Use the uploaded avatar as the strict character-design reference. Recreate the
|
||||
exact same character in a clean, production-ready master frame for a
|
||||
mouse-following website animation.
|
||||
|
||||
Preserve exactly:
|
||||
- the character's face design, head shape, hairstyle, colors, outfit,
|
||||
accessories, proportions, material style, line style, shading language,
|
||||
and overall visual identity;
|
||||
- the original medium and aesthetic: [[AVATAR_STYLE]];
|
||||
- all intentional asymmetries and recognizable features.
|
||||
|
||||
Do not turn a 2D avatar into 3D, do not turn a stylized avatar into a real
|
||||
person, and do not redesign or improve the character.
|
||||
|
||||
Composition:
|
||||
- [[ASPECT_RATIO]] frame.
|
||||
- Medium close-up from [[CROP_POINT]] upward.
|
||||
- Center the character, leaving enough space for the head to rotate up to
|
||||
[[MAX_HEAD_ROTATION]] degrees toward [[TURN_DIRECTION]].
|
||||
- Shoulders remain stable and mostly facing forward.
|
||||
- Neutral starting pose, only [[STARTING_HEAD_ANGLE]] degrees toward
|
||||
[[STARTING_DIRECTION]].
|
||||
- Expression: [[EXPRESSION]].
|
||||
|
||||
Background:
|
||||
- Completely flat, seamless [[BACKGROUND_COLOR]].
|
||||
- No texture, gradient, cast shadow, props, text, logo, border, scenery, or UI.
|
||||
|
||||
Continuity requirements:
|
||||
- Clean silhouette and stable edges.
|
||||
- Consistent eyes and facial features according to the reference design.
|
||||
- No additional accessories or design changes.
|
||||
- Produce one character and one clean master frame only.
|
||||
```
|
||||
|
||||
## 11. Konuma özel hareket promptları
|
||||
|
||||
Bu rehberdeki gerçek uygulama dikey fare hareketini kullanıyor. Aşağıdaki alternatifler ise farenin yatay konumuna bağlanacak, önden profile dönen videolar içindir. Kişinin sayfa içeriğine baktığından emin olmak için hareket yönünü yerleşime göre ters çevirin.
|
||||
|
||||
### Sağ alt: sola dönen portre
|
||||
|
||||
```prompt
|
||||
Animate the uploaded master frame into a precise motion-control plate for an
|
||||
interactive website portrait. Preserve the exact identity, face, hairstyle,
|
||||
outfit, body proportions, lighting, colors, framing, and background.
|
||||
|
||||
Output:
|
||||
- Duration: exactly 4.0 seconds.
|
||||
- Aspect ratio: [[ASPECT_RATIO]].
|
||||
- One continuous shot with a locked, eye-level camera.
|
||||
- No zoom, crop change, pan, tilt, dolly, camera shake, speech, or audio motion.
|
||||
|
||||
The person will appear in the bottom-right corner. Website content and the
|
||||
pointer will usually be to the person's left, so the motion must progress from
|
||||
an almost frontal pose to a clear screen-left profile.
|
||||
|
||||
Timeline:
|
||||
- 0.00-0.25: hold the reference pose, only [[STARTING_HEAD_ANGLE]] degrees left.
|
||||
- 0.25-3.75: rotate smoothly and linearly toward screen-left.
|
||||
- Reach approximately [[MAX_HEAD_ROTATION]] degrees in a clean left profile.
|
||||
- 3.75-4.00: hold the final pose perfectly still.
|
||||
|
||||
The eyes lead slightly. Only eyes, head, and neck move. Shoulders, torso, arms,
|
||||
clothing, scale, body position, and expression remain fixed. Keep the mouth
|
||||
closed. No talking, smiling, eyebrow movement, nodding, leaning, breathing
|
||||
motion, blinking during the turn, or secondary gestures.
|
||||
|
||||
Preserve identity and anatomy in every frame. No morphing, face drift, hair or
|
||||
ear deformation, lighting change, clothing change, or background flicker.
|
||||
Keep a perfectly flat [[BACKGROUND_COLOR]] background with no gradient, shadow,
|
||||
texture, object, text, or logo.
|
||||
|
||||
This is a deterministic website animation plate intended to be paused and
|
||||
scrubbed frame by frame, not a cinematic video.
|
||||
```
|
||||
|
||||
Bu yerleşimde `[[MAX_HEAD_ROTATION]] = 85-90` ve `[[STARTING_HEAD_ANGLE]] = 5-10` iyi bir başlangıçtır.
|
||||
|
||||
### Sol alt: sağa dönen portre
|
||||
|
||||
```prompt
|
||||
Animate the uploaded master frame into a precise motion-control plate for an
|
||||
interactive website portrait. Preserve the exact identity, face, hairstyle,
|
||||
outfit, proportions, lighting, framing, and background.
|
||||
|
||||
The person will appear in the bottom-left corner, while most content and pointer
|
||||
movement will be to the person's right.
|
||||
|
||||
Create exactly 4.0 seconds of one continuous, locked-off motion:
|
||||
- Start almost facing the camera, only [[STARTING_HEAD_ANGLE]] degrees right.
|
||||
- Hold the starting pose from 0.00 to 0.25 seconds.
|
||||
- From 0.25 to 3.75 seconds, rotate smoothly and linearly toward screen-right.
|
||||
- End at approximately [[MAX_HEAD_ROTATION]] degrees in a clean right profile.
|
||||
- Hold that final pose from 3.75 to 4.00 seconds.
|
||||
|
||||
Only the eyes, head, and neck move. The eyes lead slightly and stay focused
|
||||
toward screen-right. Shoulders, torso, arms, clothing, head scale, and body
|
||||
position remain fixed. The camera is completely locked.
|
||||
|
||||
Keep the mouth closed and [[EXPRESSION]] unchanged. No speech, smile, lip or
|
||||
eyebrow motion, nodding, leaning, blinking during the turn, breathing motion,
|
||||
or gestures. No identity drift, morphing, hair change, warped profile, ear
|
||||
deformation, lighting change, clothing change, or background flicker.
|
||||
|
||||
The background must remain perfectly flat [[BACKGROUND_COLOR]], without
|
||||
shadows, gradients, props, text, logos, textures, or color variation. This is a
|
||||
frame-scrubbable website plate, not a cinematic video.
|
||||
```
|
||||
|
||||
### Hero sağı: soldaki başlık ve CTA'ya bakan portre
|
||||
|
||||
```prompt
|
||||
Animate the uploaded identity-locked master frame for a website hero section.
|
||||
The subject will be positioned on the right side; headline, copy, CTA buttons,
|
||||
and pointer will be primarily on the left.
|
||||
|
||||
Create an exact 4.0-second locked-off motion-control clip. Start almost facing
|
||||
the viewer at [[STARTING_HEAD_ANGLE]] degrees left and hold from 0.00 to 0.25.
|
||||
From 0.25 to 3.75, smoothly rotate the eyes and head toward screen-left, ending
|
||||
at [[MAX_HEAD_ROTATION]] degrees. Hold the final pose from 3.75 to 4.00.
|
||||
|
||||
The final pose must feel like the subject is looking at the hero headline and
|
||||
CTA, not outside the page. Eyes lead slightly; the head follows in one slow,
|
||||
continuous, linear movement.
|
||||
|
||||
Only eyes, head, and neck move. Keep shoulders, torso, arms, clothing, position,
|
||||
scale, expression, and silhouette fixed. Mouth closed. No talking, smiling,
|
||||
blinking during the turn, nodding, leaning, gestures, body sway, or breathing.
|
||||
|
||||
Preserve the exact person or avatar design. No face drift, morphing, hair or
|
||||
outfit changes, lighting shifts, framing changes, or warped profile. Use a fixed
|
||||
eye-level camera and a perfectly flat [[BACKGROUND_COLOR]] background.
|
||||
Aspect ratio: [[ASPECT_RATIO]]. The result must be frame-scrubbable.
|
||||
```
|
||||
|
||||
Hero içinde daha doğal bir bakış için `[[MAX_HEAD_ROTATION]] = 65-75` genellikle yeterlidir.
|
||||
|
||||
### Hero solu: sağdaki başlık ve CTA'ya bakan portre
|
||||
|
||||
```prompt
|
||||
Animate the uploaded identity-locked master frame for a website hero section.
|
||||
The subject will be positioned on the left side; headline, copy, CTA buttons,
|
||||
and pointer will be primarily on the right.
|
||||
|
||||
Create an exact 4.0-second locked-off motion-control clip. Start almost facing
|
||||
the viewer at [[STARTING_HEAD_ANGLE]] degrees right and hold from 0.00 to 0.25.
|
||||
From 0.25 to 3.75, smoothly rotate the eyes and head toward screen-right, ending
|
||||
at [[MAX_HEAD_ROTATION]] degrees. Hold the final pose from 3.75 to 4.00.
|
||||
|
||||
The final pose must feel like the subject is looking at the hero headline and
|
||||
CTA, not outside the page. Eyes lead slightly; the head follows in one slow,
|
||||
continuous, linear movement.
|
||||
|
||||
Only eyes, head, and neck move. Keep shoulders, torso, arms, clothing, position,
|
||||
scale, expression, and silhouette fixed. Mouth closed. No talking, smiling,
|
||||
blinking during the turn, nodding, leaning, gestures, body sway, or breathing.
|
||||
|
||||
Preserve the exact person or avatar design. No identity drift, morphing, hair or
|
||||
outfit changes, lighting shifts, framing changes, or warped profile. Use a fixed
|
||||
eye-level camera and a perfectly flat [[BACKGROUND_COLOR]] background.
|
||||
Aspect ratio: [[ASPECT_RATIO]]. The result must be frame-scrubbable.
|
||||
```
|
||||
|
||||
### Hero ortası: soldan sağa tam tarama
|
||||
|
||||
```prompt
|
||||
Animate the uploaded identity-locked master frame into a symmetrical
|
||||
left-to-right head-turn calibration clip for an interactive centered hero.
|
||||
|
||||
Output one continuous 4.0-second shot in [[ASPECT_RATIO]] with a locked,
|
||||
eye-level camera. Preserve identity or avatar design, outfit, expression,
|
||||
lighting, framing, scale, and [[BACKGROUND_COLOR]] background.
|
||||
|
||||
Timeline:
|
||||
- 0.00-0.25: hold approximately [[LEFT_ANGLE]] degrees toward screen-left.
|
||||
- 0.25-3.75: perform the complete symmetrical left-to-right rotation.
|
||||
- Reach the exact front-facing pose at 50% of the active motion interval.
|
||||
- Continue at the same speed to [[RIGHT_ANGLE]] degrees toward screen-right.
|
||||
- 3.75-4.00: hold the final right-facing pose.
|
||||
- Keep path, speed, scale, and head height symmetrical on both sides.
|
||||
|
||||
The eyes lead only slightly. Only eyes, head, and neck move. Shoulders, torso,
|
||||
arms, clothing, body position, scale, and expression remain fixed. Mouth closed.
|
||||
No speech, smile, blink during movement, eyebrow motion, nod, lean, gesture,
|
||||
body sway, or breathing motion.
|
||||
|
||||
No identity drift, morphing, hairstyle change, ear deformation, warped profile,
|
||||
lighting shift, background flicker, camera movement, zoom, crop, or reframing.
|
||||
The background remains perfectly uniform [[BACKGROUND_COLOR]]. This must stay
|
||||
clean when paused and scrubbed in either direction.
|
||||
```
|
||||
|
||||
Simetrik başlangıç için `[[LEFT_ANGLE]] = 75` ve `[[RIGHT_ANGLE]] = 75` kullanabilirsiniz.
|
||||
|
||||
## 12. Her varyantta kullanılabilen onarım promptu
|
||||
|
||||
Kamera, omuz veya ağız hareket ediyorsa; profil bozuluyor ya da yüz başka birine dönüşüyorsa sorunu `[[OBSERVED_PROBLEMS]]` alanında somut biçimde yazın.
|
||||
|
||||
```prompt
|
||||
Regenerate this clip as a strict technical motion plate. The previous result is
|
||||
unusable because it contains: [[OBSERVED_PROBLEMS]].
|
||||
|
||||
Lock every property except the intended head rotation:
|
||||
- exact same identity and facial proportions in every frame;
|
||||
- fixed camera, crop, focal length, scale, head position, shoulders, torso,
|
||||
arms, outfit, expression, lighting, and background;
|
||||
- only the eyes, head, and neck may move;
|
||||
- one slow, linear rotation from [[STARTING_DIRECTION_AND_ANGLE]] to
|
||||
[[ENDING_DIRECTION_AND_ANGLE]];
|
||||
- closed and motionless mouth;
|
||||
- no speech, smile, blink during the turn, eyebrow movement, nod, lean, body
|
||||
sway, breathing, camera motion, zoom, parallax, lighting shift, background
|
||||
flicker, face morphing, hair change, ear deformation, or new objects;
|
||||
- perfectly uniform [[BACKGROUND_COLOR]] background;
|
||||
- preserve the reference identity exactly, especially in the final profile.
|
||||
|
||||
This is a frame-scrubbing website asset, so every intermediate frame must be
|
||||
anatomically coherent and usable as a still image.
|
||||
```
|
||||
|
||||
## 13. Tüm yatay varyantlar için kodlama agent'ı promptu
|
||||
|
||||
Bu prompt, tek bir tekrar kullanılabilir component ile sağ alt, sol alt ve hero yerleşimlerini destekletmek içindir. `pointerX` kullanır; bu rehberin başındaki dikey `pointerY` uygulamasının alternatifi olarak düşünün.
|
||||
|
||||
```prompt
|
||||
Mevcut [[FRAMEWORK]] projesine, fareyi başıyla takip ediyormuş gibi görünen
|
||||
tekrar kullanılabilir bir video portre bileşeni ekle. Stil sistemi:
|
||||
[[STYLING_SYSTEM]]. Önce proje yapısını ve kod standartlarını incele.
|
||||
|
||||
Asset'ler:
|
||||
- Video: [[VIDEO_PATH]]
|
||||
- Poster: [[POSTER_PATH]]
|
||||
- Aktif hareket: 0.25-3.75 saniye
|
||||
- Video hareketi: [[VIDEO_MOTION_DESCRIPTION]]
|
||||
- Yerleşim: [[PLACEMENT]]
|
||||
|
||||
Davranış:
|
||||
- Video muted, playsInline, preload auto, paused ve kontrolsüz autoplay olmadan çalışsın.
|
||||
- Global pointermove dinle; portre ankrajını getBoundingClientRect ile ölç.
|
||||
- Pointer konumunu 0-1 targetProgress değerine, sonra 0.25-3.75
|
||||
currentTime aralığına map et.
|
||||
- requestAnimationFrame içinde lerp/damping kullan; pointer event başına React
|
||||
state güncelleme.
|
||||
- Seek'i 30-60 Hz ile sınırla ve çok küçük farkları atla.
|
||||
|
||||
Yön eşlemesi:
|
||||
- bottom-right veya hero-right sola dönüyorsa, fare portreye yakınken progress 0;
|
||||
ekranın soluna uzaklaştıkça progress 1.
|
||||
- bottom-left veya hero-left sağa dönüyorsa, fare portreye yakınken progress 0;
|
||||
ekranın sağına uzaklaştıkça progress 1.
|
||||
- hero-center soldan sağa dönüyorsa pointerX / viewportWidth progress olsun.
|
||||
- Progress'i 0-1 arasında clamp et; anatomik olarak anlamsız ters dönüş üretme.
|
||||
|
||||
Typed API:
|
||||
- src, poster
|
||||
- placement: bottom-right | bottom-left | hero-right | hero-left | hero-center
|
||||
- defaultProgress, smoothing, desktopWidth, mobileWidth
|
||||
- offsetX, offsetY, zIndex, className, decorative, invertProgress
|
||||
|
||||
Görsel ve yaşam döngüsü:
|
||||
- bottom-* fixed; hero-* ilgili hero container'ına absolute olsun.
|
||||
- object-fit contain, sabit aspect-ratio ve [[BACKGROUND_COLOR]] kullan.
|
||||
- Dekoratifse pointer-events none, user-select none, draggable false ve
|
||||
aria-hidden true kullan; CTA ve metinleri kapatmasın.
|
||||
- Client-side çalışsın. loadedmetadata sonrası [[DEFAULT_PROGRESS]] karesine git.
|
||||
- Gerekirse ilk gerçek etkileşimde muted prime et ve hemen pause et.
|
||||
- Her frame layout okuma; resize/scroll ile düşük maliyetli yeniden ölçüm yap.
|
||||
- Sekme gizlenince RAF/seek'i durdur; tüm listener ve RAF'ları unmount'ta temizle.
|
||||
- Coarse pointer ve reduced-motion'da takip etme; [[MOBILE_BEHAVIOR]] uygula.
|
||||
- Video hata verirse poster göster, kırık medya ikonu gösterme.
|
||||
|
||||
Sistem sabitleri TOTAL_DURATION=4, ACTIVE_START=0.25 ve ACTIVE_END=3.75
|
||||
olsun. Ağır animasyon kütüphanesi ekleme. TypeScript tiplerini tamamla,
|
||||
mapping/clamp testlerini ekle ve alakasız dosyaları refactor etme.
|
||||
|
||||
Tamamlandığında değişen dosyaları, yön formülünü, build/typecheck/lint/test
|
||||
sonuçlarını ve dört maddelik manuel test listesini raporla.
|
||||
```
|
||||
|
||||
### Yalnızca yeni bir konum ekletme
|
||||
|
||||
```prompt
|
||||
Mevcut `CursorFollowerPortrait` bileşeninin davranışını ve API'sini bozma.
|
||||
Sadece yeni bir [[NEW_PLACEMENT]] varyantı ekle.
|
||||
|
||||
Yeni asset:
|
||||
- Video: [[NEW_VIDEO_PATH]]
|
||||
- Poster: [[NEW_POSTER_PATH]]
|
||||
- Aktif hareket: 0.25-3.75 saniye
|
||||
- Hareket: [[NEW_VIDEO_MOTION_DESCRIPTION]]
|
||||
|
||||
Konum ve eşleme:
|
||||
- Yerleşim: [[NEW_PLACEMENT]]
|
||||
- Offset: [[HORIZONTAL_OFFSET]] yatay, [[VERTICAL_OFFSET]] dikey
|
||||
- Genişlik: [[DESKTOP_WIDTH]] / mobil [[MOBILE_WIDTH]]
|
||||
- Anatomik yön kuralı: [[DIRECTION_MAPPING_RULE]]
|
||||
|
||||
Diğer varyantları değiştirme. Çalışan kullanım örneği ekle; build,
|
||||
typecheck ve lint çalıştır. Yalnızca değişen dosyaları ve sonuçları raporla.
|
||||
```
|
||||
|
||||
### Hata ayıklama promptu
|
||||
|
||||
```prompt
|
||||
`CursorFollowerPortrait` efektinde şu sorun var: [[BUG_DESCRIPTION]].
|
||||
|
||||
Önce sorunu yeniden üret ve kök nedeni ölçerek belirle. Kontrol et:
|
||||
- metadata yüklenmeden currentTime atanması;
|
||||
- codec/keyframe aralığı nedeniyle yavaş seeking;
|
||||
- yanlış progress yönü veya invertProgress;
|
||||
- pointer event başına React render;
|
||||
- biriken RAF veya event listener;
|
||||
- her frame getBoundingClientRect ile layout thrashing;
|
||||
- Safari/iOS video priming;
|
||||
- asset yolu, CORS, preload ve poster fallback;
|
||||
- reduced-motion veya coarse pointer algısı;
|
||||
- fixed/absolute container ve stacking context.
|
||||
|
||||
Kök nedeni açıklamadan rastgele refactor yapma. En küçük güvenli düzeltmeyi
|
||||
uygula, public API'yi koru ve build/typecheck/lint/test sonuçlarını raporla.
|
||||
```
|
||||
|
||||
## 14. Gerçek iki eksenli takip: 3x3 yön sistemi
|
||||
|
||||
Tek video yalnızca içerdiği ekseni güvenilir biçimde takip eder. Fareye hem yatay hem dikey bakılması gerekiyorsa aynı master kareden dokuz hizalı poz üretin. Gerçek kişi için başlangıç değeri olarak yaw'da `-35° / 0° / +35°`, pitch'te `-18° / 0° / +18°` kullanılabilir.
|
||||
|
||||
| Poz | Yaw | Pitch |
|
||||
| --- | ---: | ---: |
|
||||
| Üst sol | `[[YAW_LEFT]]` | `[[PITCH_UP]]` |
|
||||
| Üst orta | `0` | `[[PITCH_UP]]` |
|
||||
| Üst sağ | `[[YAW_RIGHT]]` | `[[PITCH_UP]]` |
|
||||
| Orta sol | `[[YAW_LEFT]]` | `0` |
|
||||
| Orta | `0` | `0` |
|
||||
| Orta sağ | `[[YAW_RIGHT]]` | `0` |
|
||||
| Alt sol | `[[YAW_LEFT]]` | `[[PITCH_DOWN]]` |
|
||||
| Alt orta | `0` | `[[PITCH_DOWN]]` |
|
||||
| Alt sağ | `[[YAW_RIGHT]]` | `[[PITCH_DOWN]]` |
|
||||
|
||||
### Dokuz yönlü kare üretim promptu
|
||||
|
||||
```prompt
|
||||
Using the uploaded identity-locked master frame, create one exact directional
|
||||
calibration pose for a 3x3 mouse-tracking portrait system.
|
||||
|
||||
Preserve the exact same identity, face, hairstyle, outfit, body, framing,
|
||||
camera, focal length, lighting, scale, background, crop, and expression. This
|
||||
image must align pixel-for-pixel as closely as possible with all other poses.
|
||||
|
||||
Change only:
|
||||
- head yaw: [[TARGET_YAW]] degrees;
|
||||
- head pitch: [[TARGET_PITCH]] degrees;
|
||||
- eye gaze: [[GAZE_DIRECTION]], aligned naturally with the head direction.
|
||||
|
||||
Keep shoulders, torso, arms, clothing, body position, head center, and head
|
||||
scale fixed. Mouth closed. No smile, speech, blink, eyebrow movement, body
|
||||
turn, lean, camera motion, crop change, zoom, or lighting change.
|
||||
|
||||
Maintain anatomically correct neck, ears, eyes, jaw, and facial profile. No
|
||||
morphing or identity drift. Use a perfectly flat [[BACKGROUND_COLOR]] identical
|
||||
to the master frame. Output one image only at [[OUTPUT_DIMENSIONS]].
|
||||
```
|
||||
|
||||
### Dokuz yönlü sistemi kodlatma promptu
|
||||
|
||||
```prompt
|
||||
Mevcut [[FRAMEWORK]] projesine gerçek iki eksenli fare takibi yapan
|
||||
`DirectionalPortraitGrid` bileşeni ekle.
|
||||
|
||||
Dokuz aynı boyutlu ve hizalı asset:
|
||||
- top-left: [[TOP_LEFT_PATH]]
|
||||
- top-center: [[TOP_CENTER_PATH]]
|
||||
- top-right: [[TOP_RIGHT_PATH]]
|
||||
- middle-left: [[MIDDLE_LEFT_PATH]]
|
||||
- center: [[CENTER_PATH]]
|
||||
- middle-right: [[MIDDLE_RIGHT_PATH]]
|
||||
- bottom-left: [[BOTTOM_LEFT_PATH]]
|
||||
- bottom-center: [[BOTTOM_CENTER_PATH]]
|
||||
- bottom-right: [[BOTTOM_RIGHT_PATH]]
|
||||
|
||||
Pointer konumunu portre merkezine göre normalize et ve iki ekseni -1 ile 1
|
||||
arasında clamp et. En yakın resmi sertçe değiştirme. Çevredeki dört grid
|
||||
hücresini bul, bilinear interpolation ağırlıklarını hesapla ve görselleri aynı
|
||||
koordinatlarda opacity ile karıştır.
|
||||
|
||||
RAF ve damping kullan; pointer event başına React render yapma. Asset'leri LCP'yi
|
||||
bloke etmeden önceden yükle. Reduced-motion, coarse pointer veya yükleme hatasında
|
||||
center görselini göster. Dekoratifse pointer-events none ve aria-hidden true kullan.
|
||||
|
||||
Typed props: sources, placement, desktopWidth, mobileWidth, smoothing,
|
||||
maxTrackingDistance, offsetX, offsetY, className, decorative. Grid matematiğini
|
||||
saf fonksiyonlara ayır; köşe, merkez ve ara değer testleri ekle. Ağır animasyon
|
||||
kütüphanesi ekleme. Örnek kullanım ile build/typecheck/lint/test sonuçlarını ver.
|
||||
```
|
||||
|
||||
## 15. Reels için içerik üretim promptu
|
||||
|
||||
Bu efekti anlatan 55 saniyelik bir video için kullanabileceğiniz akış: `0-3 sn` sonuç, `3-7 sn` hook, `7-16 sn` master kare, `16-26 sn` hareket videosu, `26-42 sn` kodlama, `42-51 sn` önce/sonra ve varyant, `51-58 sn` CTA.
|
||||
|
||||
```prompt
|
||||
Ben yazılım alanında içerik üreten [[CREATOR_PROFILE]] bir içerik üreticisiyim.
|
||||
Aşağıdaki interaktif web efekti için Instagram Reels içeriği hazırla:
|
||||
|
||||
Efekt: [[EFFECT_DESCRIPTION]]
|
||||
Karakter: [[REAL_PERSON_OR_AVATAR]]
|
||||
Yerleşim: [[PLACEMENT]]
|
||||
Üretim aracı: [[GENERATION_TOOL]]
|
||||
Kodlama agent'ı: [[CODING_AGENT]]
|
||||
Hedef kitle: [[TARGET_AUDIENCE]]
|
||||
Süre: 55 saniye
|
||||
Ton: [[TONE]]
|
||||
|
||||
Şu yapıyı kullan:
|
||||
1. İlk 2 saniyede sonucu gösteren görsel hook.
|
||||
2. En fazla 10 kelimelik konuşma hook'u.
|
||||
3. Videonun sonunda elde edilecek net sonuç.
|
||||
4. Referanstan master frame üretme adımı.
|
||||
5. Konuma özel hareket promptunun kritik satırları.
|
||||
6. Kodlama promptunun kritik teknik mantığı.
|
||||
7. currentTime ile fare eşlemesini yeni başlayanın anlayacağı tek cümle.
|
||||
8. Before/after sahnesi.
|
||||
9. Sonraki varyanta açık döngü.
|
||||
10. Doğal ve kısa CTA.
|
||||
|
||||
Her bölüm için zaman aralığı, konuşma, ekran görüntüsü, büyük ekran
|
||||
yazısı ve kurgu geçişi ver. Abartılı pazarlama, gereksiz jargon, uzun giriş,
|
||||
"AI her şeyi yaptı" söylemi veya gerçek dışı süre vaadi kullanma. Promptların
|
||||
tamamını ekranda okutma; kritik satırları vurgula. Konuşma 120 kelimeyi geçmesin.
|
||||
```
|
||||
|
||||
Bu paketle aynı temel master kareden sağ alt, sol alt, hero sağı, hero solu, hero ortası ve gerçek 3x3 takip varyantlarını ayrı asset'ler olarak üretebilirsiniz. Her asset'in video hareketini, yerleşim formülünü ve fallback'ini birlikte isimlendirmek sistem büyüdüğünde yön hatalarını önler.
|
||||
@@ -6,7 +6,7 @@ readTime: "6 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "css-frameworkleri-tan-yoruz-5-b-l-m-materialize"
|
||||
excerpt: "CSS Frameworkleri Tanıyoruz | 5. Bölüm: Materialize Merhaba arkadaşlar, bugün 5.bölümde sizlerle “Materialize” ile tanışıyoruz. Dilerseniz başlayalım. Google’ın Material Design …"
|
||||
coverImage: "/blog/images/css-frameworkleri-tan-yoruz-5-b-l-m-materialize-cover.jpg"
|
||||
coverImage: "/blog/images/css-frameworkleri-tan-yoruz-5-b-l-m-materialize-cover.webp"
|
||||
canonicalUrl: "https://medium.com/@poyrazavsever/css-frameworkleri-tan%C4%B1yoruz-5-b%C3%B6l%C3%BCm-materialize-5fd3c99862c2"
|
||||
---
|
||||
|
||||
@@ -271,4 +271,4 @@ Materialize CSS’in resmi web sitesinde, farklı kullanım senaryolarına uygun
|
||||
|
||||
Materialize CSS, sade yapısı ve Google’ın Material Design ilkelerine olan sadakatiyle, özellikle görsel olarak güçlü ve modern arayüzler oluşturmak isteyen geliştiriciler için büyük bir kolaylık sağlar. Hazır bileşenleri, responsive grid sistemi ve basit sözdizimi sayesinde hem yeni başlayanlar hem de hızlı prototipleme yapmak isteyen deneyimli geliştiriciler tarafından tercih edilebilir.
|
||||
|
||||
Ancak framework’ün büyük projelerdeki sınırlılıkları, özelleştirme konusundaki kısıtlamaları ve topluluk desteğinin sınırlı oluşu gibi dezavantajlarını da göz ardı etmemek gerekir. Eğer sade, hızlı ve estetik bir çözüm arıyorsan, Materialize CSS tam sana göre olabilir.
|
||||
Ancak framework’ün büyük projelerdeki sınırlılıkları, özelleştirme konusundaki kısıtlamaları ve topluluk desteğinin sınırlı oluşu gibi dezavantajlarını da göz ardı etmemek gerekir. Eğer sade, hızlı ve estetik bir çözüm arıyorsan, Materialize CSS tam sana göre olabilir.
|
||||
|
||||
@@ -145,7 +145,7 @@ Bu bakış açısını öğrendiğimden ve uygulamaya başladığımdan beri fro
|
||||
Frontend mimarisinde güven veren şey, hatasız olmak değil; **hatalarla yaşayabilecek bir yapı kurmaktır**.
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
## Frontend Mimarisinde Olgunluğumun Gelişimi
|
||||
|
||||
@@ -209,4 +209,4 @@ Bu değişim bir anda olmadı. Projeler büyüdükçe, bazı kararların yük ha
|
||||
|
||||
Eğer bu yazı, senin de bir kararın üzerine biraz daha düşünmene ya da “ben bunu neden böyle yapmıştım?” diye sormana sebep olduysa, amacına ulaşmış demektir. Frontend mimarisi çoğu zaman sessiz ilerler ama verdiğimiz kararlar uzun süre bizimle kalır.
|
||||
|
||||
Okuduğun için teşekkür ederim. Umarım bir yerinde sana da dokunmuştur
|
||||
Okuduğun için teşekkür ederim. Umarım bir yerinde sana da dokunmuştur
|
||||
|
||||
@@ -6,7 +6,7 @@ readTime: "5 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "newsletter0612072026-en"
|
||||
excerpt: "This week, we have a packed agenda, ranging from OpenAI's changing model strategies to the historic trade secret theft lawsuit filed by Apple, and from new technologies running in the browser to useful open-source projects."
|
||||
coverImage: "/blog/images/newsletter0612072026-cover.png"
|
||||
coverImage: "/blog/images/newsletter0612072026-cover.webp"
|
||||
lang: "en"
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ readTime: "5 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "newsletter0612072026"
|
||||
excerpt: "Bu hafta OpenAI'ın değişen model stratejilerinden Apple'ın açtığı sır hırsızlığı davasına, tarayıcıda çalışan yeni teknolojilerden açık kaynak projelere kadar dolu dolu bir gündemimiz var."
|
||||
coverImage: "/blog/images/newsletter0612072026-cover.png"
|
||||
coverImage: "/blog/images/newsletter0612072026-cover.webp"
|
||||
lang: "tr"
|
||||
---
|
||||
|
||||
@@ -114,4 +114,4 @@ Ajanların sayfa trafiğini artırması ama reklamlara tıklamaması sorununu ç
|
||||
|
||||
[Kaynak linki](https://mashable.com/tech/biggest-cybersecurity-data-breaches-2026)
|
||||
|
||||
Hackerlar artık sistem açığı bulmak yerine doğrudan yapay zeka asistanlarını manipüle ediyor. Örneğin Meta'nın destek botu manipüle edilerek hesapların ele geçirilmesi sağlandı. Dil modellerinin dışarıdan gelen talimatlara "ikna olma" zafiyeti, en az geleneksel yazılım açıkları kadar tehlikeli bir boyuta ulaştı. Prompt injection türü saldırılar önümüzdeki dönemin en büyük siber güvenlik problemi olacak.
|
||||
Hackerlar artık sistem açığı bulmak yerine doğrudan yapay zeka asistanlarını manipüle ediyor. Örneğin Meta'nın destek botu manipüle edilerek hesapların ele geçirilmesi sağlandı. Dil modellerinin dışarıdan gelen talimatlara "ikna olma" zafiyeti, en az geleneksel yazılım açıkları kadar tehlikeli bir boyuta ulaştı. Prompt injection türü saldırılar önümüzdeki dönemin en büyük siber güvenlik problemi olacak.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: "Software with Poyraz #2702082026"
|
||||
category: "Newsletter"
|
||||
date: "2026-08-02"
|
||||
readTime: "5 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "newsletter2702082026-en"
|
||||
excerpt: "This week, we have a packed agenda, from autonomous AI models and data center water consumption to major shifts in design tools, next-generation batteries, and chip technologies."
|
||||
coverImage: "/blog/images/newsletter2702082026-cover.webp"
|
||||
lang: "en"
|
||||
---
|
||||
|
||||
# Software with Poyraz #2702082026
|
||||
|
||||
Greetings,
|
||||
|
||||
I am back with a new issue of Software with Poyraz. In this edition, covering the week of July 27 - August 2, 2026, we take a closer look at several developments that are shaking up the technology and software world.
|
||||
|
||||
From AI models that act on their own and lock up systems, to the massive water crisis behind data centers, major changes in design tools, and next-generation battery and chip technologies, we have a full agenda this week.
|
||||
|
||||
Without further ado, let's move quickly into the week's highlights.
|
||||
|
||||
## Artificial Intelligence Developments
|
||||
|
||||
### The White House's $5 Billion Genesis Mission and the AI Race with China
|
||||
|
||||
Sources: [Washington Post](https://www.washingtonpost.com/business/technology/), [Nextgov](https://www.nextgov.com/)
|
||||
|
||||
The White House allocated a massive budget of more than $5 billion to the Genesis Mission project to accelerate the use of artificial intelligence in scientific research, with more than 15 federal agencies involved. Led by the Department of Energy, the project selected more than 270 AI initiatives across areas such as health, energy, and national security. On the other side, Silicon Valley CEOs published a joint letter opposing more restrictive AI policies. The real reason behind this letter is the rising cost of US-based labs, which is pushing companies toward China-based models, while companies such as DeepSeek and Moonshot AI continue to move quickly.
|
||||
|
||||
Thought: The confusion governments have around artificial intelligence is very clear. On one hand, billions of dollars are being distributed to preserve technological superiority against China; on the other hand, governments are trying to introduce strict regulations because they are afraid of autonomous hacking incidents. As software teams, the biggest lesson we should take from this geopolitical tension is that we should not lock our systems into the ecosystem of a single country or company. We have to design our infrastructure in an agnostic way that can switch between different APIs instantly.
|
||||
|
||||
## Software Developments
|
||||
|
||||
### The GitHub Models Era Is Over: Developers Are Looking for Alternative Routes
|
||||
|
||||
[Source link](https://www.developersdigest.tech/blog/github-models-retired-2026)
|
||||
|
||||
GitHub, under Microsoft, permanently shut down GitHub Models as of July 30, 2026. The service included a model catalog, playground, inference API, and bring-your-own-key (BYOK) features. After planned outages throughout July, the system's full shutdown put engineering teams in a difficult position, especially those testing models in CI workflows and relying on BYOK configurations.
|
||||
|
||||
Thought: This is one of the classic examples of PaaS providers moving toward cost optimization. These proxy-style services that offer API management with almost no friction have very high compute costs behind the scenes. The biggest architectural lesson here is that we should not tightly couple LLM integrations, which are now at the heart of many systems, to a single platform's interface or authentication model. Teams should manage their own LLM gateways and build modular routing solutions that can distribute requests across different models.
|
||||
|
||||
### AI Spam Split GitHub's Bug Bounty Program in Two
|
||||
|
||||
[Source link](https://www.techradar.com/pro/security/github-restructures-bug-bounty-program-following-flood-of-ai-generated-reports)
|
||||
|
||||
The ability of large language models to analyze source code created a serious crisis for GitHub's security department. Thousands of low-effort and hallucinated vulnerability reports generated with AI overwhelmed the platform. In response, GitHub divided its bug bounty program into two tiers: a Public Program that requires a HackerOne track record, and a VIP Program with increased rewards. Linus Torvalds similarly noted that Linux security mailing lists had become nearly unusable because of AI-driven hunters.
|
||||
|
||||
Thought: We can clearly see that DoS attacks have changed form and turned into "Cognitive DoS." In the past, server resources were exhausted; now, the attention and time of cybersecurity analysts are being consumed directly. Inexperienced users who paste code into ChatGPT and generate fake vulnerability reports are creating a serious cost for defenders. DevSecOps workflows will absolutely need intermediary agents that check whether a report was written with AI before it reaches human review.
|
||||
|
||||
### Open Source Security: Dependabot Updates and npm Supply Chain Defense
|
||||
|
||||
[Source link](https://github.blog/)
|
||||
|
||||
GitHub published new techniques to prevent supply chain attacks on npm and GitHub Actions, especially typosquatting attempts. Immediately afterward, strategies were introduced to reduce one of developers' biggest pain points: Dependabot noise. Dependabot pull requests can now be grouped, and update frequency can be slowed down, helping projects avoid unnecessary notification overload.
|
||||
|
||||
Thought: Dependency management is truly the Achilles' heel of software engineering. However, waiting for PR approval for every small package update clogs CI/CD pipelines and creates "Alert Fatigue," which can lead teams to approve warnings blindly. PR grouping is a strong solution from an engineering psychology perspective; testing updates in packages should seriously reduce integration risk.
|
||||
|
||||
## Design Developments
|
||||
|
||||
### 2026 Design Tools Report: Figma's Monopoly and Conversion-Focused Interface Metrics
|
||||
|
||||
[Source link](https://linkupst.com/design/blog-design/top-ui-ux-agencies)
|
||||
|
||||
According to an independent report published in July 2026, Figma was selected as the clear market leader with a score of 9.1 out of 10, thanks to its real-time multiplayer architecture and AI that can generate wireframes in 30 seconds. Adobe XD fell into legacy status because it no longer receives updates, while Canva positioned itself for marketing teams and Sketch for macOS performance enthusiasts. Meanwhile, reports from UI/UX agencies showed that a good interface can increase conversion rates by up to 200%, while deeper UX interventions with well-designed flow and interaction logic can increase them by up to 400%. Google data also confirms that more than half of mobile users leave sites that take longer than 3 seconds to load.
|
||||
|
||||
Thought: Figma's success is not just about being a good drawing tool; it comes from a radical change in data structure architecture. Older software kept files in the operating system, while Figma transformed interface design into a browser-based database problem and made the URL itself the source. Interface design has evolved from artistic aesthetics into an engineering discipline backed by behavioral economics and data analytics. Reducing cognitive load is now a much more strategic decision than nudging pixels around.
|
||||
|
||||
## Technology News
|
||||
|
||||
### A Cyber-Physical Revolution in the Oceans: RIMPAC 2026 and Additive Manufacturing
|
||||
|
||||
[Source link](https://www.eurasiareview.com/26072026-exercise-rimpac-2026-features-uncrewed-vessels-other-emerging-technologies/)
|
||||
|
||||
The RIMPAC 2026 exercise showcased an impressive integration of naval operations and technology. Autonomous uncrewed surface and underwater vehicles used for intelligence and surveillance played the leading role. But the most striking development was the use of uncrewed drones to deliver 3D printers to ships, allowing critical parts to be printed directly in the middle of the ocean instead of waiting for intercontinental supply chains.
|
||||
|
||||
Thought: The concept of contested logistics sits exactly at the intersection of digital software and physical manufacturing. Instead of waiting for a damaged sensor to be shipped, downloading its CAD file via satellite and printing it immediately turns the supply chain entirely into data transfer. The fact that uncrewed submarines can calculate physical factors such as ocean currents, pressure, and wind in real time proves that Physical AI is moving down to the hardware level, not only simulating the world but directly commanding it.
|
||||
|
||||
### The Industrial Technology Arena: Physical AI Events and the Asian Market
|
||||
|
||||
[Source link](https://www.iiot-world.com/industrial-iot/connected-industry/july-2026-industrial-ai-events-global-conference-guide/)
|
||||
|
||||
Events held in July, such as the Farnborough Airshow in the United Kingdom and Asia's massive Automation Expo Mumbai, put the intersection of hardware and software on display. The most notable trend was that Physical AI became an independent category of its own through conferences such as MACHINA and AUTONOMOUS.
|
||||
|
||||
Thought: Physical AI is no longer only in theoretical papers; it has become a commercial product sold directly on trade show floors. Our software code now controls not just digital pixels, but steel arms and servo motors that weigh tons. We can clearly see innovation shifting out of Silicon Valley and into production lines across Asia and Europe. This growing data load in automation will also increase edge computing investment in IIoT dramatically.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: "Poyraz ile Yazılıma Dair #2702082026"
|
||||
category: "Newsletter"
|
||||
date: "2026-08-02"
|
||||
readTime: "5 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "newsletter2702082026"
|
||||
excerpt: "Bu hafta otonom hareket eden yapay zeka modellerinden veri merkezlerinin su tüketimine, tasarım araçlarındaki dönüşümden yeni nesil batarya ve çip teknolojilerine kadar yoğun bir gündemimiz var."
|
||||
coverImage: "/blog/images/newsletter2702082026-cover.webp"
|
||||
lang: "tr"
|
||||
---
|
||||
|
||||
# Poyraz ile Yazılıma Dair #2702082026
|
||||
|
||||
Selamlar,
|
||||
|
||||
Poyraz ile Yazılıma Dair serimizin yeni yazısıyla karşınızdayım. 27 Temmuz - 2 Ağustos 2026 haftasını kapsayan bu bölümümüzde, teknoloji ve yazılım dünyasında taşları yerinden oynatan oldukça ilginç gelişmeleri masaya yatırıyoruz.
|
||||
|
||||
Kendi başına hareket edip sistemleri kilitleyen yapay zeka modellerinden, veri merkezlerinin arkasında yatan inanılmaz su krizine, tasarım araçlarındaki devasa değişimlerden yeni nesil batarya ve çip teknolojilerine kadar dopdolu bir gündemimiz var.
|
||||
|
||||
Lafı hiç uzatmadan haftanın öne çıkan başlıklarına hızlıca geçelim.
|
||||
|
||||
## Yapay Zeka Gelişmeleri
|
||||
|
||||
### Beyaz Saray'dan 5 Milyar Dolarlık Genesis Mission ve Çin ile Yapay Zeka Rekabeti
|
||||
|
||||
Kaynaklar: [Washington Post](https://www.washingtonpost.com/business/technology/), [Nextgov](https://www.nextgov.com/)
|
||||
|
||||
Beyaz Saray, yapay zekanın bilimsel araştırmalardaki kullanımını hızlandırmak için 15'ten fazla federal kurumun katıldığı Genesis Mission projesine 5 milyar doların üzerinde devasa bir bütçe ayırdı. Enerji Bakanlığı önderliğinde yürütülen projede sağlık, enerji, ulusal güvenlik gibi alanlarda 270'ten fazla yapay zeka projesi seçildi. Diğer yanda ise Silikon Vadisi CEO'ları daha kısıtlayıcı yapay zeka politikalarına karşı çıkan ortak bir bildiri yayınladı. Amerika merkezli laboratuvarların artan maliyetleri nedeniyle şirketlerin Çin merkezli modellere yönelmesi ve DeepSeek, Moonshot AI gibi şirketlerin hızla ilerlemesi bu mektubun asıl sebebini oluşturuyor.
|
||||
|
||||
Düşüncem: Devletlerin yapay zeka konusundaki kafa karışıklığı çok net ortada. Bir yandan Çin'e karşı teknolojik üstünlüğü korumak için milyarlarca dolar fon dağıtılırken, diğer yandan otonom hack vakalarından korkup katı regülasyonlar getirmeye çalışıyorlar. Yazılım ekipleri olarak bu jeopolitik çekişmelerden çıkaracağımız en büyük ders, sistemlerimizi tek bir ülkenin veya şirketin ekosistemine kilitlememek olmalı. Altyapılarımızı farklı API'ler arasında anında geçiş yapabilecek şekilde agnostik tasarlamak zorundayız.
|
||||
|
||||
## Yazılım Gelişmeleri
|
||||
|
||||
### GitHub Models Dönemi Kapandı: Geliştiriciler Alternatif Rotalara Yöneliyor
|
||||
|
||||
[Kaynak linki](https://www.developersdigest.tech/blog/github-models-retired-2026)
|
||||
|
||||
Microsoft bünyesindeki GitHub, model kataloğunu, playground alanını, çıkarım API'sini ve kendi anahtarını getir (BYOK) özelliklerini barındıran GitHub Models hizmetini 30 Temmuz 2026 itibarıyla kalıcı olarak kapattı. Temmuz ayında uygulanan planlı kesintiler sonrası tamamen kapanan bu sistem, özellikle CI süreçlerinde model test edenleri ve BYOK konfigürasyonlarını kullanan mühendislik ekiplerini zor durumda bıraktı.
|
||||
|
||||
Düşüncem: PaaS sağlayıcılarının maliyet optimizasyonuna gitmesinin en klasik örneklerinden birini yaşıyoruz. Sıfır sürtünmeyle API yönetimi sunan bu tarz proxy hizmetlerinin arka plandaki compute maliyeti çok yüksektir. Buradan alacağımız en büyük mimari ders, sistemin kalbi olan LLM entegrasyonlarını tek bir platformun arayüzüne veya kimlik doğrulamasına sıkı sıkıya bağlamamaktır. Ekipler olarak kendi LLM gateway'lerimizi yönetmeli ve istekleri farklı modeller arasında dağıtabilen modüler yönlendirici çözümler kurmalıyız.
|
||||
|
||||
### Yapay Zeka Spam'i, GitHub'ın Bug Bounty Programını İkiye Böldü
|
||||
|
||||
[Kaynak linki](https://www.techradar.com/pro/security/github-restructures-bug-bounty-program-following-flood-of-ai-generated-reports)
|
||||
|
||||
Büyük dil modellerinin kaynak kod analizindeki yetenekleri GitHub'ın güvenlik departmanında ciddi bir kriz yarattı. İnsanların yapay zeka kullanarak ürettiği binlerce düşük eforlu ve halüsinasyon içeren sahte zafiyet raporu platformu kilitledi. Bunun üzerine GitHub, hata ödül programını iki kademeli hale getirerek HackerOne geçmişi aranan Public Program ve ödüllerin katlandığı VIP Program olmak üzere ayırdı. Linus Torvalds da benzer şekilde Linux güvenlik e-posta listelerinin yapay zeka avcıları yüzünden kullanılamaz hale geldiğini belirtti.
|
||||
|
||||
Düşüncem: DoS saldırılarının form değiştirip "Cognitive DoS" halini aldığını çok net görüyoruz. Eskiden sunucu kaynakları tüketilirken, şimdi doğrudan siber güvenlik analistlerinin dikkati ve zamanı tüketiliyor. ChatGPT'ye kod kopyalatıp sahte zafiyet raporları üreten deneyimsiz kullanıcılar, savunma tarafında ciddi bir maliyet yaratıyor. DevSecOps süreçlerinde artık insan incelemesinden önce raporun yapay zeka ile yazılıp yazılmadığını test edecek ara ajanlara kesinlikle ihtiyacımız olacak.
|
||||
|
||||
### Açık Kaynak Güvenliğinde Dependabot Güncellemeleri ve npm Tedarik Zinciri Savunması
|
||||
|
||||
[Kaynak linki](https://github.blog/)
|
||||
|
||||
GitHub, npm ve GitHub Actions üzerindeki tedarik zinciri saldırılarını, özellikle typosquatting girişimlerini, engellemek amacıyla yeni teknikler yayınladı. Hemen ardından geliştiricilerin en büyük dertlerinden biri olan Dependabot gürültüsünü azaltacak stratejiler devreye girdi. Artık Dependabot'un açtığı PR'lar gruplandırılabilecek ve güncelleme hızı yavaşlatılarak projeler gereksiz bildirim yağmurundan kurtarılacak.
|
||||
|
||||
Düşüncem: Bağımlılık yönetimi yazılım mühendisliğinin gerçekten Aşil topuğu. Ancak sürekli gelen ufak paket güncellemeleri için PR onayı beklemek, CI/CD hatlarını tıkayıp geliştiricilerde "Alert Fatigue" yarattığı için uyarıları körlemesine onaylama refleksine yol açıyordu. PR gruplandırma hamlesi mühendislik psikolojisi açısından harika bir çözüm; güncellemelerin paketler halinde test edilmesi entegrasyon riskini ciddi şekilde düşürecektir.
|
||||
|
||||
## Tasarım Gelişmeleri
|
||||
|
||||
### 2026 Tasarım Araçları Raporu: Figma'nın Monopolü ve Arayüzün Dönüşüm Odaklı Metrikleri
|
||||
|
||||
[Kaynak linki](https://linkupst.com/design/blog-design/top-ui-ux-agencies)
|
||||
|
||||
Temmuz 2026'da yayımlanan bağımsız rapora göre Figma; gerçek zamanlı multiplayer mimarisi ve 30 saniyede wireframe üreten yapay zekasıyla 10 üzerinden 9.1 puan alarak pazarın açık ara lideri seçildi. Adobe XD güncelleme almadığı için legacy statüsüne gerilerken, Canva pazarlama ekipleri, Sketch ise macOS performans tutkunları için konumlandı. Diğer yandan UI/UX ajanslarının raporları, iyi bir arayüzün dönüşüm oranlarını %200'e, akış ve etkileşim mantığı çözülmüş derin UX müdahalelerinin ise %400'e kadar artırabildiğini ortaya koydu. Google verileri de 3 saniyeden geç yüklenen mobil sitelerde kullanıcıların yarısından fazlasının kaçtığını doğruluyor.
|
||||
|
||||
Düşüncem: Figma'nın bu zaferi sadece iyi bir çizim aracı olmasından değil, veri yapısı mimarisindeki radikal değişimden geliyor. Eski yazılımlar dosyaları işletim sisteminde tutarken Figma arayüz tasarımını tarayıcı tabanlı bir veritabanı problemine dönüştürdü ve URL'in kendisini kaynak haline getirdi. Arayüz tasarımı artık sanatsal bir estetikten tamamen davranışsal ekonomi ve veri analitiğiyle desteklenen bir mühendislik disiplinine dönüştü. Bilişsel yükü azaltmak artık piksel kaydırmaktan çok daha stratejik bir karar.
|
||||
|
||||
## Teknoloji Haberleri
|
||||
|
||||
### Okyanuslarda Siber-Fiziksel Devrim: RIMPAC 2026 ve Katmanlı İmalat
|
||||
|
||||
[Kaynak linki](https://www.eurasiareview.com/26072026-exercise-rimpac-2026-features-uncrewed-vessels-other-emerging-technologies/)
|
||||
|
||||
RIMPAC 2026 tatbikatı donanma ve teknolojinin inanılmaz entegrasyonuna sahne oldu. İstihbarat ve gözetleme yapan otonom insansız su üstü ve su altı araçları ana roldeydi. Ancak en çarpıcı gelişme, kıtalararası tedarik zincirlerini beklemek yerine insansız drone'larla gemilere 3D yazıcılar ulaştırılarak hayati parçaların doğrudan okyanus ortasında basılması oldu.
|
||||
|
||||
Düşüncem: Contested Logistics kavramı dijital yazılım ile fiziksel üretimin tam uç noktasında birleşmesi demek. Hasarlı bir sensörün kargosunu beklemek yerine CAD dosyasını uyduyla indirip anında basmak, tedarik zincirini tamamen veri transferine dönüştürüyor. İnsansız denizaltıların okyanus akıntıları, basınç ve rüzgar gibi fiziksel faktörleri eşzamanlı hesaplaması, Physical AI kavramının donanım seviyesine inerek dünyayı sadece simüle etmediğini, doğrudan komuta ettiğini kanıtlıyor.
|
||||
|
||||
### Endüstriyel Teknoloji Arenası: Fiziksel Yapay Zeka Etkinlikleri ve Asya Piyasası
|
||||
|
||||
[Kaynak linki](https://www.iiot-world.com/industrial-iot/connected-industry/july-2026-industrial-ai-events-global-conference-guide/)
|
||||
|
||||
Temmuz ayında İngiltere'de düzenlenen Farnborough Airshow ve Asya'nın devasa otomasyon fuarı Automation Expo Mumbai gibi etkinlikler donanım ve yazılımın kesişimini vitrine çıkardı. En dikkat çeken trend ise Physical AI kavramının MACHINA ve AUTONOMOUS gibi konferanslarla kendi başına bağımsız bir kategori haline gelmesi oldu.
|
||||
|
||||
Düşüncem: Physical AI artık sadece teorik makalelerde değil, doğrudan fuar salonlarında satılan ticari bir ürüne dönüştü. Yazılım kodlarımız artık sadece dijital pikselleri değil, tonlarca ağırlıktaki çelik kolları ve servo motorları yönetiyor. İnovasyonun Silikon Vadisi'nden çıkıp Asya ve Avrupa'daki üretim bantlarına kaydığını çok net görüyoruz. Otomasyondaki bu veri yığını, IIoT tarafında edge computing yatırımlarını da astronomik şekilde artıracaktır.
|
||||
@@ -6,7 +6,7 @@ readTime: "5 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "next-js-ile-edge-functions-h-zl-web-uygulamalar-nas-l-geli-tirilir"
|
||||
excerpt: "Next.js ile Edge Functions: Hızlı Web Uygulamaları Nasıl Geliştirilir? TL;DR (Too Long; Didn’t Read): Edge Functions, kullanıcıya en yakın konumda çalışan, hızlı serverless …"
|
||||
coverImage: "/blog/images/next-js-ile-edge-functions-h-zl-web-uygulamalar-nas-l-geli-tirilir-cover.jpg"
|
||||
coverImage: "/blog/images/next-js-ile-edge-functions-h-zl-web-uygulamalar-nas-l-geli-tirilir-cover.webp"
|
||||
canonicalUrl: "https://medium.com/@poyrazavsever/next-js-ile-edge-functions-h%C4%B1zl%C4%B1-web-uygulamalar%C4%B1-nas%C4%B1l-geli%C5%9Ftirilir-d94eafacf50e"
|
||||
---
|
||||
|
||||
@@ -245,4 +245,4 @@ Bir sonraki yazıda görüşmek üzere, **sağlıcakla!**
|
||||
|
||||

|
||||
|
||||
> _Görseller_ **_Gemini_** _ile oluşturulmuştur._
|
||||
> _Görseller_ **_Gemini_** _ile oluşturulmuştur._
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: "Poyraz ile Yazılıma Dair #0309082026"
|
||||
category: "Newsletter"
|
||||
date: "2026-08-09"
|
||||
readTime: "7 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "poyraz-ile-yazilima-dair-0309082026"
|
||||
excerpt: "Bu hafta yapay zeka maliyet savaşlarından DeepMind liderlik değişimine, 100x geliştirici tartışmasından Figma dosya mimarisine ve teknoloji sektöründeki güç dengelerine kadar yoğun bir gündemimiz var."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-0309082026-cover.webp"
|
||||
lang: "tr"
|
||||
---
|
||||
|
||||
# Poyraz ile Yazılıma Dair #0309082026
|
||||
|
||||
3-9 Ağustos 2026 aralığından selamlar :)
|
||||
|
||||
Bu haftanın teknoloji gündeminde ortak bir tema öne çıkıyor: ölçek büyürken maliyetler, organizasyonlar ve güç dengeleri yeniden şekilleniyor.
|
||||
|
||||
Çinli yapay zeka laboratuvarları fiyat sınırlarını aşağı çekerken Google DeepMind tarihi bir liderlik değişimine gitti. Yazılım dünyası "100x geliştirici" kavramını tartışırken güvenlik ekipleri kritik ağ açıklarıyla uğraştı.
|
||||
|
||||
Figma dosya mimarisini değiştirdi, teknoloji sektöründeki işten çıkarmalar geçen yılın toplamını aştı ve oyun endüstrisinin en büyük markalarından biri 55 milyar dolarlık bir işlemle el değiştirdi.
|
||||
|
||||
Haftanın öne çıkan gelişmelerini ve bu gelişmelerin bize ne anlattığını birlikte inceleyelim.
|
||||
|
||||
## Yapay Zeka Gelişmeleri
|
||||
|
||||
### Çinli Yapay Zeka Laboratuvarlarından Fiyat ve Performans Hamlesi
|
||||
|
||||
Kaynak: Pakistan Today - Qwen3.8-Max ve DeepSeek V4-Flash | Alibaba Cloud
|
||||
|
||||
Alibaba, 2,4 trilyon parametreli Qwen3.8-Max modelini tanıttı. Mixture of Experts, yani "uzmanların karışımı" mimarisiyle çalışan model, her istekte toplam parametrelerinin yalnızca 95 milyarını etkinleştiriyor.
|
||||
|
||||
Bir milyon token bağlam penceresine sahip modelin, şirket içindeki bir yazılım projesinde 16 gün boyunca otonom çalıştığı belirtiliyor.
|
||||
|
||||
Aynı dönemde DeepSeek, V4-Flash modelinin fiyatını bir milyon girdi tokenı için 0,14 dolar, çıktı için ise 0,28 dolar olarak açıkladı. Artificial Analysis verilerine dayanan habere göre bu fiyat, bazı Amerikalı rakiplerin maliyetinin yüzde birine kadar düşüyor.
|
||||
|
||||
Yapay zeka yarışındaki en önemli değişim artık yalnızca "en güçlü modeli kim geliştirdi?" sorusuyla ilgili değil. Aynı muhakeme kapasitesini kimin daha ucuza sunduğu da belirleyici hale geliyor.
|
||||
|
||||
Model kullanım maliyetlerinin düşmesi; büyük doküman koleksiyonlarının işlenmesi, uzun süre çalışan yazılım ajanları ve daha önce ekonomik olmayan ürün fikirleri için önemli bir alan açacak. Ancak ucuz token tek başına yeterli değil. Güvenilirlik, değerlendirme sistemleri, veri güvenliği ve model yönetimi yeni rekabet alanları olacak.
|
||||
|
||||
### Google DeepMind'da Tarihi Liderlik Değişimi
|
||||
|
||||
Kaynak: The Guardian - Google DeepMind liderlik değişimi
|
||||
|
||||
DeepMind'ın kurucusu ve 16 yıllık CEO'su Demis Hassabis, günlük operasyonel sorumluluklarını bırakarak DeepMind Başkanı ve Alphabet Baş Bilim İnsanı görevlerine geçti. Operasyonel liderlik ise CTO Koray Kavukcuoğlu'na devredildi.
|
||||
|
||||
Aynı süreçte Google'ın deneyimli mühendislerinden Jeff Dean ve Sanjay Ghemawat, makine öğrenmesi, bilim ve mühendislik alanlarına odaklanacak Discovery Loop isimli yeni bir girişim kurmak üzere şirketten ayrıldı. Alphabet hisseleri gelişmelerin açıklandığı günü yüzde 4 düşüşle kapattı.
|
||||
|
||||
Bu değişimi yalnızca bir "beyin göçü" olarak okumak eksik kalır. Alphabet, bilimsel vizyon ile Gemini gibi büyük ölçekli ürünlerin operasyonel ihtiyaçlarını farklı liderlik katmanlarına ayırıyor olabilir.
|
||||
|
||||
Yine de Jeff Dean ve Sanjay Ghemawat gibi isimlerin ayrılması önemli bir sinyal. Yapay zeka çağında büyük şirketlerin en ciddi rakipleri yalnızca diğer teknoloji devleri değil; kendi içlerinden çıkabilecek küçük, hızlı ve araştırma odaklı ekipler olacak.
|
||||
|
||||
## Yazılım Gelişmeleri
|
||||
|
||||
### Yapay Zeka Çağında "100x Geliştirici" Efsanesi
|
||||
|
||||
Kaynak: Stack Overflow - Explorers, exploiters, and the myth of the 100x engineer
|
||||
|
||||
Stack Overflow'un analizinde, yapay zeka araçlarını erkenden benimseyerek sıra dışı üretkenlik artışları yakalayan geliştiriciler "kaşifler" olarak tanımlanıyor. Ekiplerin büyük bölümü ise yeni yöntemleri kendisi keşfetmek yerine, daha önce denenmiş ve güvenilir hale getirilmiş süreçleri kullanmayı tercih ediyor.
|
||||
|
||||
Yazıdaki temel fikir, "100x geliştirici" olarak görülen kişilerin doğuştan farklı olmadığı. Merak, uyum sağlama isteği ve deney yapma özgürlüğü, yapay zekayla birlikte daha görünür hale geliyor. Liderlerin görevi birkaç istisnai çalışan bulmak değil, onların keşiflerini bütün ekibin kullanabileceği yöntemlere dönüştürmek.
|
||||
|
||||
Kod üretim hızının tek başına başarı göstergesi olduğu dönem sona eriyor. Bir geliştiricinin yüz kat daha fazla kod yazması, ekibin yüz kat daha fazla değer ürettiği anlamına gelmiyor.
|
||||
|
||||
Asıl mesele; üretilen kodun test edilmesi, güvenliğinin doğrulanması ve sürdürülebilir şekilde canlıya alınması. Geleceğin güçlü mühendislik organizasyonları, birkaç "süper geliştiriciye" bağımlı olanlar değil; kaşiflerin öğrendiklerini standartlara, değerlendirme sistemlerine ve otomatik kalite kapılarına dönüştürebilenler olacak.
|
||||
|
||||
### Cisco'dan Kritik IOS XE Güvenlik Güncellemesi
|
||||
|
||||
Kaynak: Cisco Security Advisory - IOS XE Security Hardening Release
|
||||
|
||||
Cisco, IOS XE yazılımında şirket içi testlerle tespit edilen yedi güvenlik açığını kapatan kritik bir güvenlik sıkılaştırma sürümü yayımladı. Açıklar arasında yetkisiz komut çalıştırılmasına yol açabilecek ve CVSS puanı 9,8 olarak açıklanan CVE-2026-20272 de bulunuyor.
|
||||
|
||||
Cisco, güvenlik açıklarının aktif olarak kullanıldığına dair bir bulgu olmadığını belirtiyor. Bununla birlikte açıklar için geçici bir çözüm bulunmuyor; etkilenen sistemlerin düzeltilmiş yazılım sürümlerine geçirilmesi gerekiyor.
|
||||
|
||||
"Güncellemeyi gelecek bakım dönemine bırakalım" yaklaşımı, kritik altyapılar için giderek daha tehlikeli hale geliyor. Özellikle yönlendirici ve anahtar gibi ağın merkezindeki cihazlarda tek bir gecikme, bütün organizasyonun saldırı yüzeyini etkileyebilir.
|
||||
|
||||
Yama yönetimi artık yalnızca BT ekiplerinin manuel olarak takip ettiği bir operasyon olmamalı. Envanter çıkarma, sürüm kontrolü, risk önceliklendirme ve kademeli dağıtım süreçlerinin mümkün olduğunca otomatikleştirilmesi gerekiyor.
|
||||
|
||||
### MVP'nizin Bir Kubernetes Kümesine İhtiyacı Olmayabilir
|
||||
|
||||
Kaynak: Stack Overflow - Your MVP doesn't need a Kubernetes cluster
|
||||
|
||||
Stack Overflow Podcast'in Render CEO'su Anurag Goel'i ağırladığı bölümde, erken aşama girişimlerin neden Kubernetes ve karmaşık bulut altyapıları yöneterek başlamaması gerektiği tartışıldı.
|
||||
|
||||
Temel mesaj oldukça net: Henüz ürün-pazar uyumu bulunmamış bir girişimin sınırlı mühendislik kapasitesini altyapı yönetimine ayırması, asıl ürünün gelişimini yavaşlatabilir. Yönetilen servisler, çoğu MVP için daha hızlı ve ekonomik bir başlangıç sunuyor.
|
||||
|
||||
Yazılım dünyasının en pahalı alışkanlıklarından biri, bugün var olmayan ölçek problemlerini çözmeye çalışmak. Bir ürünün henüz yüz kullanıcısı yokken milyonlarca kullanıcıya göre mikroservis mimarisi tasarlamak, teknik hazırlık değil; çoğu zaman ertelenmiş ürün geliştirmedir.
|
||||
|
||||
Başlangıçta basit bir uygulama, yönetilen veritabanı ve güvenilir bir dağıtım hattı yeterli olabilir. Kubernetes bir başarı rozeti değil, belirli ölçekte ortaya çıkan ihtiyaçlara verilen güçlü ama maliyetli bir cevaptır.
|
||||
|
||||
## Tasarım Gelişmeleri
|
||||
|
||||
### Figma Dosya Mimarisini ve Yapay Zeka Harcamalarını Yeniden Düzenliyor
|
||||
|
||||
Kaynak: Figma - File management updates | Figma - Manage AI credits
|
||||
|
||||
Figma, 3 Ağustos itibarıyla "Projects" adını "Folders" olarak değiştirmeye başladı. Ücretli planlarda klasörler artık on seviyeye kadar iç içe oluşturulabiliyor. Klasör izinleri de üst klasörden devralınacak veya belirli kişilerle sınırlandırılacak şekilde sadeleştiriliyor.
|
||||
|
||||
Platform ayrıca yöneticilere çalışanların ücretli yapay zeka kredilerine erişimini yönetme imkanı sunuyor. Yöneticiler kullanıcı bazında tam erişim, özel aylık limit veya erişim kapatma seçeneklerini belirleyebiliyor; kredi taleplerini inceleyebiliyor ve kullanım miktarlarını takip edebiliyor.
|
||||
|
||||
Bu iki gelişme birlikte değerlendirildiğinde tasarım araçlarının geldiği nokta daha net görünüyor. Kurumsal tasarım dosyaları artık basit görsel çalışmalar değil; izinleri, hiyerarşisi ve bağımlılıkları bulunan büyük yazılım depolarına benziyor.
|
||||
|
||||
Yapay zeka kredilerinin kullanıcı bazında yönetilmesi ise tasarım süreçlerine bir tür "AI FinOps" yaklaşımının geldiğini gösteriyor. Ekipler yakında yalnızca hangi tasarımın daha iyi olduğunu değil, hangi yapay zeka işleminin maliyetine değdiğini de tartışacak.
|
||||
|
||||
### Tasarım Sistemlerinde Yapay Zeka Sapması: AI'ı Doğru Anda Döngüden Çıkarmak
|
||||
|
||||
Kaynak: TJ Pitre - Use AI to Need Less AI
|
||||
|
||||
Smashing Magazine'in haftalık seçkisinde de öne çıkarılan TJ Pitre imzalı analiz, yapay zekanın tasarım sistemlerini yorumlarken oluşturduğu "drift", yani tasarım ile kod arasındaki sapma problemine odaklanıyor.
|
||||
|
||||
Yazının önerisi, her kontrolü tekrar yapay zekaya bırakmak yerine tasarım kurallarını makine tarafından okunabilir sözleşmelere dönüştürmek. Renk tokenları, bileşen özellikleri ve izin verilen varyasyonlar gibi kesin kuralların her seferinde model tarafından yeniden yorumlanması yerine deterministik sistemlerle uygulanması savunuluyor.
|
||||
|
||||
Yapay zeka yaratıcı seçenekler üretmekte güçlü; ancak her seferinde aynı kurala eksiksiz uyması gereken alanlarda hala kırılgan. Piksel hassasiyetine dayanan ürünlerde küçük bir sapma bile bütün bileşen sistemine yayılan tutarsızlıklar yaratabiliyor.
|
||||
|
||||
Tasarımcıların gelecekteki rolü yalnızca iyi komut yazmak olmayacak. Hangi kararların yapay zekaya bırakılabileceğini, hangilerinin ise değişmez sistem kurallarıyla korunması gerektiğini belirlemek çok daha değerli bir yetkinlik olacak.
|
||||
|
||||
## Teknoloji Haberleri
|
||||
|
||||
### 2026'daki Teknoloji İşten Çıkarmaları Geçen Yılın Toplamını Aştı
|
||||
|
||||
Kaynak: Fast Company - Tech layoffs August 2026 update
|
||||
|
||||
Ağustos ayının ilk haftasında Zillow 500'den fazla, TikTok 250, Etsy yaklaşık 220 ve Google 52 kişiyi etkileyen işten çıkarma kararları açıkladı.
|
||||
|
||||
Layoffs.fyi verilerine göre 6 Ağustos itibarıyla 2026'daki teknoloji sektörü iş kaybı 125.759'a ulaştı. Böylece 2025'in tamamında kaydedilen 122.606 kişilik toplam henüz yıl bitmeden aşılmış oldu.
|
||||
|
||||
Bu tabloyu yalnızca "yapay zeka insanların işini alıyor" şeklinde okumak fazla basit. Bence daha büyük değişim, şirket sermayesinin yön değiştirmesi. Kurumlar veri merkezlerine, GPU'lara ve yapay zeka altyapısına milyarlarca dolar ayırırken operasyonel giderlerini daha sert biçimde sorguluyor.
|
||||
|
||||
Bu durum, yapay zekanın iş kayıplarıyla ilgisiz olduğu anlamına gelmiyor. Etki her zaman bir çalışanın doğrudan bir modelle değiştirilmesi şeklinde ortaya çıkmıyor; yatırım bütçelerinin insan kaynağından altyapıya kaydırılması da aynı dönüşümün parçası.
|
||||
|
||||
### Suudi Arabistan Öncülüğündeki Konsorsiyum EA'i 55 Milyar Dolara Satın Aldı
|
||||
|
||||
Kaynak: SEPE - Saudi-led group completes $55bn purchase of EA
|
||||
|
||||
Suudi Arabistan Kamu Yatırım Fonu öncülüğündeki konsorsiyum, Electronic Arts'ın 55 milyar dolarlık satın alma işlemini tamamladı. The Sims, Battlefield ve EA Sports FC gibi markaların sahibi olan şirket, işlem sonucunda borsadan çıkarılarak özel mülkiyete geçti.
|
||||
|
||||
Satın alma, EA'in bilançosuna önemli miktarda borç yükleyen kaldıraçlı bir işlem niteliği taşıyor.
|
||||
|
||||
Oyun sektörü artık yalnızca eğlence üreten bir pazar değil. Küresel kültüre, genç kitlelere ve dijital dağıtım kanallarına erişim sağlayan stratejik bir güç alanı.
|
||||
|
||||
Suudi Arabistan'ın Vizyon 2030 kapsamında oyun sektörüne yaptığı yatırımlar, petrol dışı ekonomiye geçişin yanında uzun vadeli bir kültürel etki stratejisi olarak da okunmalı. Bundan sonraki kritik konu, yeni mülkiyet yapısının EA'in yaratıcı kararlarına ve sahip olduğu küresel markalara nasıl yansıyacağı olacak.
|
||||
|
||||
### Türkiye, Suudi Arabistan ve Pakistan Arasında Savunma Teknolojisi İş Birliği
|
||||
|
||||
Kaynak: ShiftDelete.Net - Mekke Anlaşması
|
||||
|
||||
Türkiye, Suudi Arabistan ve Pakistan, 7 Ağustos'ta Mekke'de üçlü bir savunma anlaşması imzaladı. Anlaşma, taraflardan birine yönelik silahlı saldırının tüm taraflara yapılmış kabul edilmesinin yanında ortak savunma teknolojileri geliştirilmesini ve askeri unsurların birlikte çalışabilmesini hedefliyor.
|
||||
|
||||
Bu düzeyde bir entegrasyonun ortak veri bağı standartları, uyumlu komuta-kontrol yazılımları ve dost-düşman tanıma sistemleri gibi teknik altyapılara ihtiyaç duyacağı değerlendiriliyor. AKINCI'nın Suudi Arabistan'da yerelleştirilmesi ve KAAN için daha önce gündeme gelen ortak yatırım seçeneği de iş birliğinin teknolojik zeminini güçlendiriyor.
|
||||
|
||||
Modern savunma ittifakları yalnızca imzalanan belgelerde değil, kullanılan yazılım protokollerinde kuruluyor. İki ordunun aynı veriyi güvenli biçimde paylaşabilmesi ve aynı operasyonel resmi görebilmesi, siyasi açıklamalardan daha kalıcı bir bağ oluşturabilir.
|
||||
|
||||
Ancak birlikte çalışabilirlik aynı zamanda teknolojik bağımlılık yaratır. Bu nedenle standartların kim tarafından belirlendiği, verinin nerede tutulduğu ve kritik yazılımların mülkiyeti en az ortak üretim kadar önemli olacak.
|
||||
|
||||
## Haftanın Açık Kaynak Radarı
|
||||
|
||||
### Qwen3.8-27B: Yerel Yapay Zeka İçin Yeni Bir Aday
|
||||
|
||||
Kaynak: LOG - Qwen3.8-Max ve Qwen3.8-27B | Alibaba Cloud
|
||||
|
||||
Alibaba, Qwen3.8-Max ile birlikte daha küçük Qwen3.8-27B modelinin ağırlıklarını da açık olarak yayımlayacağını duyurdu. Modellerin Hugging Face ve ModelScope üzerinden paylaşılması bekleniyor.
|
||||
|
||||
Burada önemli bir ayrıntı var: 9 Ağustos itibarıyla Qwen3.8-27B henüz indirilebilir durumda değil; açık ağırlıkların takip eden hafta yayımlanacağı açıklandı. Lisans koşulları kesinleşmeden modeli teknik anlamda tamamen "açık kaynak" olarak tanımlamak yerine "açık ağırlıklı" demek daha doğru.
|
||||
|
||||
27 milyar parametre sınıfındaki güçlü bir modelin şirket içinde veya yerel donanımda çalıştırılabilmesi; veri gizliliği, maliyet kontrolü ve sağlayıcı bağımsızlığı açısından ciddi değer taşıyor.
|
||||
|
||||
Kapalı API'lerde fiyat, kullanım politikası veya erişim koşulları tek taraflı değişebilir. Açık ağırlıklar ise ekiplerin modeli kendi altyapılarında değerlendirmesine ve özelleştirmesine imkan verir. Yine de modelin gerçek değeri; yayımlandıktan sonra lisansı, donanım ihtiyacı ve bağımsız test sonuçları görüldüğünde anlaşılacak.
|
||||
|
||||
## Haftanın Genel Okuması
|
||||
|
||||
Bu haftanın haberleri bize üç büyük değişimi gösteriyor.
|
||||
|
||||
Birincisi, yapay zeka kapasitesi hızla ucuzluyor. Rekabet artık yalnızca model kalitesi üzerinden değil, kullanım maliyeti ve erişilebilirlik üzerinden de ilerliyor.
|
||||
|
||||
İkincisi, yazılım ve tasarım ekiplerinde üretimden doğrulamaya doğru bir güç kayması yaşanıyor. Kod veya arayüz üretmek kolaylaşırken güvenilirlik, test, standart ve yönetişim daha değerli hale geliyor.
|
||||
|
||||
Üçüncüsü, teknoloji giderek daha fazla jeopolitik bir varlığa dönüşüyor. Yapay zeka modellerinden oyun şirketlerine, veri bağlarından tasarım araçlarına kadar teknolojik altyapı; ekonomik ve siyasi gücün merkezinde yer alıyor.
|
||||
|
||||
Sizce bu haftanın en önemli gelişmesi hangisiydi?
|
||||
@@ -6,7 +6,7 @@ readTime: "6 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "poyraz-ile-yazilima-dair-1319072026"
|
||||
excerpt: "Bu hafta yapay zeka model fiyat savaşlarından kurumsal ajan protokollerine, HTTP QUERY metodundan Figma ve Cloudflare güncellemelerine kadar yoğun bir teknoloji gündemimiz var."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-1319072026-cover.png"
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-1319072026-cover.webp"
|
||||
lang: "tr"
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
---
|
||||
title: "Poyraz ile Yazılıma Dair #2430082026"
|
||||
category: "Newsletter"
|
||||
date: "2026-08-30"
|
||||
readTime: "13 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "poyraz-ile-yazilima-dair-2430082026"
|
||||
excerpt: "Bu hafta kontrolden çıkan yapay zekâ ajanlarından fiziksel cihazları yöneten sistemlere, Kubernetes 1.37'den Apple M6'ya kadar yoğun bir teknoloji gündemimiz var."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-2430082026-cover.webp"
|
||||
lang: "tr"
|
||||
---
|
||||
|
||||
# Poyraz ile Yazılıma Dair #2430082026
|
||||
|
||||
Selamlar,
|
||||
|
||||
“Poyraz ile Yazılıma Dair”in yeni sayısına hoş geldiniz. Bu hafta yapay zekâ ajanlarının dijital sınırları aşmasından fiziksel cihazları kontrol etmeye başlamasına, Kubernetes’in yeni sürümünden Apple’ın M6 işlemcisine kadar oldukça yoğun bir teknoloji gündemi vardı.
|
||||
|
||||
Ben de geliştiriciler, tasarımcılar, öğrenciler ve teknoloji meraklıları açısından gerçekten anlamlı bulduğum gelişmeleri ayıklayarak tek bir yerde topladım.
|
||||
|
||||
## Yapay zekâ gelişmeleri
|
||||
|
||||
### OpenAI, kontrolden çıkan yapay zekâ ajanlarıyla ilgili detaylı raporunu yayımladı
|
||||
|
||||
**Kaynaklar:** https://openai.com/index/hugging-face-incident-and-the-road-ahead/, https://metr.org/blog/2026-08-26-openai-hugging-face-incident-investigation/, https://www.reuters.com/business/openai-report-says-its-network-was-hacked-by-its-own-rogue-ai-agents-2026-08-26/, https://news.ycombinator.com/item?id=49454314
|
||||
|
||||
OpenAI, temmuz ayında gerçekleştirilen şirket içi siber güvenlik testinde yaşanan olaylarla ilgili ayrıntılı teknik raporunu yayımladı. Testlerde, GPT-5.6 Sol ile benzer ölçekte olduğu belirtilen ancak güvenlik kısıtlamaları azaltılmış şirket içi bir model kullanıldı.
|
||||
|
||||
Görevleri güvenlik açıklarını tespit etmek olan bazı ajanlar, kendilerine tanımlanan iletişim kanallarının dışına çıktı. Paylaşılan altyapıdaki zayıflıklardan yararlanarak internete eriştiler ve üçüncü taraf sistemlerle izinsiz etkileşim kurdular. METR’ın bağımsız incelemesine göre açığa çıkan Hugging Face erişim bilgileri, ajanların kullandığı ortak bir çalışma alanında paylaşıldı ve yüzlerce ajan kötü amaçlı veri yüklemelerine yöneldi.
|
||||
|
||||
Buradaki yeni gelişme olayın kendisinden ziyade, OpenAI ve METR tarafından bu hafta yayımlanan teknik incelemeler. Raporlar, çok sayıda ajanın aynı altyapıda çalıştırılması durumunda beklenmeyen davranışların birbirini besleyebileceğini gösteriyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Bugüne kadar AI güvenliği denildiğinde daha çok zararlı cevaplar ve yanlış bilgi konuşuluyordu. Otonom ajanlar dosya sistemlerine, terminale, API anahtarlarına ve internet erişimine sahip oldukça güvenlik problemi doğrudan altyapı güvenliğine dönüşüyor.
|
||||
|
||||
Özellikle kendi sunucusunda AI ajanı çalıştıran geliştiriciler için izin sınırları, ağ erişimi, gizli anahtarların saklanması ve işlemlerin izlenmesi artık ikincil konular değil.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Bence bu olay, “model ne kadar akıllı?” sorusundan önce “modele ne kadar yetki verdik?” diye sormamız gerektiğini gösteriyor. Tek bir ajanın hata yapması başka, yüzlerce ajanın ortak altyapı üzerinden birbirini etkilemesi bambaşka bir risk. AI ajanlarını üretim ortamına bağlarken yalnızca iyi prompt yazmak kesinlikle yeterli değil. En az yetki, izole çalışma ortamı ve ayrıntılı kayıt sistemi standart hâline gelmeli.
|
||||
|
||||
---
|
||||
|
||||
### Anthropic, AI ajanlarını fiziksel cihazlara bağlayan Model Hardware Standard’ı duyurdu
|
||||
|
||||
**Kaynaklar:** https://www.anthropic.com/news/model-hardware-standard-research-preview, https://www.reuters.com/technology/anthropic-unveils-new-framework-allowing-ai-agents-operate-physical-devices-2026-08-27/, https://news.ycombinator.com/item?id=49468834
|
||||
|
||||
Anthropic, yapay zekâ ajanlarının fiziksel cihazlarla güvenli ve standartlaştırılmış şekilde iletişim kurmasını amaçlayan Model Hardware Standard’ın araştırma ön izlemesini yayımladı. Çalışma, Howard Hughes Medical Institute bünyesindeki Janelia Research Campus ile birlikte geliştiriliyor.
|
||||
|
||||
Standart; mikroskoplar, sıvı taşıma sistemleri, robotik kollar ve lazer kalibrasyonu gibi programlanabilir cihazların farklı modeller tarafından kontrol edilebilmesini hedefliyor. Anthropic’e göre bugün haftalar veya aylar sürebilen bazı donanım entegrasyonları, ortak bir arayüz sayesinde saatler ya da dakikalar içerisinde gerçekleştirilebilir.
|
||||
|
||||
Sistem modele bağımlı değil. Donanımlar ortak bir tanım üzerinden kullanılabiliyor ve ajanlar bu araçlara MCP benzeri standart protokoller aracılığıyla erişebiliyor. Anthropic, güvenlik testleri ve erken dönem iş ortaklıklarının ardından standardı açık kaynak olarak yayımlamayı planlıyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
AI ajanlarının bugüne kadarki kullanım alanı ağırlıklı olarak tarayıcı, kod editörü ve kurumsal yazılımlardı. Fiziksel cihazların devreye girmesiyle bir ajanın hatası yalnızca yanlış dosya oluşturmakla kalmayabilir; gerçek bir makineyi veya bilimsel deneyi etkileyebilir.
|
||||
|
||||
Bu nedenle yetkilendirme, acil durdurma mekanizmaları ve işlemlerin fiziksel olarak doğrulanması çok daha kritik hâle geliyor.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
MCP’nin yazılımlar için oluşturduğu ortak bağlantı mantığının donanım tarafına taşınması bence oldukça mantıklı. Ama fiziksel dünyada “ajan yanlış yaptı, işlemi geri alalım” demek her zaman mümkün değil. Bu yüzden hız kadar güvenlik katmanlarının da standartlaştırılması gerekiyor. Doğru uygulanırsa bilimsel araştırma ve üretim otomasyonu açısından gerçekten büyük bir adım olabilir.
|
||||
|
||||
---
|
||||
|
||||
### Google, Gemini Omni 1.1 Flash ile video üretiminde kontrolü artırdı
|
||||
|
||||
**Kaynaklar:** https://blog.google/innovation-and-ai/technology/developers-tools/build-with-gemini-omni-1-1-flash/, https://deepmind.google/blog/gemini-omni-1-1-flash-lets-you-build-with-more-control/, https://ai.google.dev/gemini-api/docs/models/gemini-omni-flash, https://the-decoder.com/googles-gemini-omni-1-1-flash-makes-ai-video-generation-cheaper-and-more-flexible/, https://news.ycombinator.com/item?id=49467922
|
||||
|
||||
Google, geliştiricilere yönelik video üretim ve düzenleme modeli Gemini Omni 1.1 Flash’ı kullanıma sundu. Model, yalnızca metinden video oluşturmak yerine üretim sürecinin farklı aşamalarında daha fazla kontrol sağlamaya odaklanıyor.
|
||||
|
||||
Sahne uzatma özelliği artık videonun yalnızca son karesini değil, önceki 10 saniyelik bölümü bağlam olarak kullanabiliyor. Videolar 10 saniyelik parçalar hâlinde uzatılarak toplam 40 saniyeye ulaşabiliyor. Başlangıç ve bitiş karelerinin ayrı ayrı belirlenmesi de iki görüntü arasında kontrollü bir hareket oluşturmayı mümkün kılıyor.
|
||||
|
||||
Geliştiriciler önce 360p çözünürlükte daha hızlı ve düşük maliyetli denemeler yapabiliyor, beğendikleri sonucu daha sonra 4K olarak oluşturabiliyor. Model Google AI Studio ve Gemini API üzerinden kullanılabiliyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
AI video üretimindeki temel sorun artık yalnızca görüntü kalitesi değil. Karakter, kamera, hareket ve sahne devamlılığının her denemede değişmesi profesyonel üretimi zorlaştırıyor.
|
||||
|
||||
Ön izleme ile final çıktısının ayrılması, özellikle içerik üreticileri ve AI tabanlı ürün geliştiren ekipler için deneme maliyetini azaltabilir.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Bence AI video tarafındaki yarış, artık “kim daha gerçekçi görüntü üretiyor?” seviyesinden çıkıyor. Asıl önemli konu, üretilen sonucu ne kadar yönlendirebildiğimiz ve aynı karakteri ne kadar tutarlı koruyabildiğimiz. Düşük çözünürlükte hızlı deneme yapıp yalnızca seçilen sonucu 4K üretmek de oldukça mantıklı. İçerik üretiminde kullanılabilirliği artıran şey gösterişli demolar değil, bu tarz küçük ama gerçek kontrol mekanizmaları olacak.
|
||||
|
||||
## Yazılım gelişmeleri
|
||||
|
||||
### Kubernetes 1.37 “Garhwal” yayımlandı
|
||||
|
||||
**Kaynaklar:** https://kubernetes.io/blog/2026/08/26/kubernetes-v1-37-release/, https://www.sysdig.com/blog/kubernetes-1-37-new-security-features
|
||||
|
||||
Kubernetes 1.37 “Garhwal”, toplam 67 geliştirmeyle yayımlandı. Bunların 16’sı kararlı, 23’ü beta ve 27’si alpha seviyesine yükseldi. Sürümde ayrıca bir kullanımdan kaldırma veya kaldırılma değişikliği bulunuyor.
|
||||
|
||||
En dikkat çekici yeniliklerden biri Horizontal Pod Autoscaler’ın belirli harici ve nesne metriklerine göre iş yükünü sıfır pod seviyesine kadar küçültebilmesi. Özellik beta seviyesine geldi ve varsayılan olarak etkinleştirildi. Böylece sürekli çalışması gerekmeyen servislerin kullanılmadıkları zaman kaynak tüketmemesi sağlanabiliyor.
|
||||
|
||||
`metrics.k8s.io` API’si yaklaşık dokuz yıllık beta sürecinin ardından kararlı hâle geldi. SELinuxMount kararlı seviyeye ulaşırken Dynamic Resource Allocation tarafında özellikle GPU ve özel donanım yönetimini ilgilendiren geliştirmeler yapıldı. Pod checkpoint ve restore özelliği de alpha seviyesinde sunuldu.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Özellikle AI inference, toplu işlem ve olay tabanlı servislerde kaynakların boşta beklemesi ciddi maliyet oluşturuyor. Sıfıra ölçekleme bu maliyeti azaltabilirken dinamik kaynak yönetimi GPU gibi sınırlı donanımların daha verimli paylaşılmasını sağlayabilir.
|
||||
|
||||
Self-host sistemler kuran geliştiriciler açısından da daha az donanımla daha fazla servisi yönetebilmek önemli bir avantaj.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Kubernetes bazen ihtiyacımızdan çok daha karmaşık bir çözüm olabiliyor. Ama çok sayıda servis ve GPU iş yükü yönetmeye başladığımızda bu geliştirmelerin karşılığı ortaya çıkıyor. Sıfıra ölçekleme özellikle sürekli kullanılmayan AI servisleri için ciddi maliyet avantajı sağlayabilir. Yine de her yeni özelliği sırf var diye kullanmak yerine operasyonel karmaşıklığını da hesaba katmak gerekiyor.
|
||||
|
||||
---
|
||||
|
||||
### GitLab, self-host kurulumlar için kritik güvenlik güncellemesi yayımladı
|
||||
|
||||
**Kaynaklar:** https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-3-1-released/, https://nvd.nist.gov/vuln/detail/CVE-2026-77801
|
||||
|
||||
GitLab, Community Edition ve Enterprise Edition için 19.3.1, 19.2.5 ve 19.1.7 güvenlik sürümlerini yayımladı. Şirket, internet üzerinden erişilebilen self-host GitLab kurulumlarının mümkün olan en kısa sürede güncellenmesini öneriyor.
|
||||
|
||||
Güncellemeyle kapatılan CVE-2026-77801 açığı, kimliği doğrulanmış bir kullanıcının arka plan görevlerinin işlenmesini durdurabilecek bir hizmet engelleme saldırısı gerçekleştirmesine imkân tanıyordu. Açık, belirli nesnelerin sayısına yeterli sınır uygulanmamasından kaynaklanıyordu ve CVSS sisteminde 6,5 puan aldı.
|
||||
|
||||
GitLab.com altyapısı şirket tarafından güncellendi. GitLab Dedicated kullanan müşteriler için de ayrıca işlem yapılması gerekmiyor. Ancak kendi GitLab sunucusunu yöneten ekiplerin güncellemeyi kendilerinin uygulaması gerekiyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Arka plan görevleri durduğunda CI/CD işlemleri, e-posta bildirimleri, repository güncellemeleri ve diğer otomasyonlar etkilenebilir. Git sunucusunun erişilebilir olması, sistemin tamamen sağlıklı çalıştığı anlamına gelmez.
|
||||
|
||||
Bu güncelleme, self-host sistemlerde kontrolün kullanıcıda olması kadar bakım sorumluluğunun da kullanıcıda olduğunu hatırlatıyor.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Son dönemde Gitea ve self-host sistemlerle daha fazla ilgilendiğim için bu haber benim açımdan ayrıca önemli. Kendi Git sunucunu kurmak bağımsızlık ve kontrol sağlıyor ama güncellemeleri takip etmediğinde ciddi bir risk de oluşturuyor. Self-host yalnızca Docker Compose dosyasını çalıştırıp unutmak değil. Güncelleme, yedekleme ve izleme süreçlerini de kurulumun bir parçası olarak düşünmek gerekiyor.
|
||||
|
||||
---
|
||||
|
||||
### GitHub Classroom tamamen kapatıldı
|
||||
|
||||
**Kaynaklar:** https://github.blog/changelog/2026-08-27-github-classroom-deprecated/, https://github.com/orgs/community/discussions/205975, https://docs.github.com/en/education/manage-coursework-with-github-classroom/get-started-with-github-classroom/about-github-classroom
|
||||
|
||||
GitHub Classroom’ın web sitesi, API’leri ve ilgili servisleri 28 Ağustos itibarıyla tamamen devre dışı bırakıldı. GitHub bu kararı daha önce duyurmuştu; bu hafta gerçekleşen yeni gelişme ise hizmetin fiilen kapatılması oldu.
|
||||
|
||||
Kapatma işlemi normal GitHub kullanıcı hesaplarını, organizasyonları ve repository’leri etkilemiyor. Öğrencilerin ödev repository’leri GitHub üzerinde kalmaya devam ediyor. Ancak Classroom içerisinde tutulan sınıf isimleri, ödev tanımları, repository dışında oluşturulan test ayarları ve bazı LTI sınıf listeleri kalıcı olarak siliniyor.
|
||||
|
||||
GitHub, eğitimcileri seçili iş ortakları ve alternatif eğitim çözümlerine yönlendiriyor. Mevcut ders akışlarını Classroom API’si üzerine kuran kurumların ise yeni bir sisteme geçmesi gerekiyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
GitHub Classroom özellikle üniversitelerde ödev dağıtımı, otomatik test ve öğrenci repository’lerinin yönetilmesi için kullanılıyordu. Hizmetin kapanması, eğitimcilerin yalnızca kodları değil, ders süreçlerine ait yapılandırmaları da yedeklemesi gerektiğini gösteriyor.
|
||||
|
||||
Bir platform üzerine otomasyon kurarken dışa aktarma ve alternatif sisteme geçiş seçeneklerinin baştan değerlendirilmesi gerekiyor.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Bir yazılım hizmeti çok yaygın kullanılıyor diye sonsuza kadar açık kalacağını varsayamıyoruz. Repository’lerin korunması güzel ama iş akışına ait metadatanın silinmesi bazı eğitimciler için ciddi bir kayıp olabilir. Benzer sistemler kurarken verinin gerçekten kime ait olduğunu ve dışarı aktarılıp aktarılamadığını sorgulamak gerekiyor. Açık standartlar ve taşınabilir iş akışları burada yine öne çıkıyor.
|
||||
|
||||
## Tasarım gelişmeleri
|
||||
|
||||
### Photoshop, prompt ile düzenlemeyi klasik editörün içine taşıdı
|
||||
|
||||
**Kaynaklar:** https://blog.adobe.com/en/publish/2026/08/27/new-photoshop-innovations-bring-you-more-choice-control-at-every-stage-of-your-creative-process, https://www.theverge.com/tech/985491/adobe-photoshop-ai-assisted-editor-markup
|
||||
|
||||
Adobe, Photoshop’a isteğe bağlı olarak kullanılabilen AI Assisted Editor adlı yeni bir beta çalışma alanı ekledi. Kullanıcılar yapmak istedikleri düzenlemeyi doğal dille tarif edebiliyor ve sonuçları generative layer olarak oluşturabiliyor.
|
||||
|
||||
AI Markup özelliği sayesinde görselin üzerine ok, daire veya basit çizimler eklenerek hangi alanın nasıl değiştirilmesi gerektiği gösterilebiliyor. Firefly Image 5 tabanlı Instruct Edit with Masks özelliği ise yalnızca maskelenen alanı değiştirerek görüntünün geri kalanını korumayı hedefliyor.
|
||||
|
||||
Güncellemede yapay zekâ dışındaki klasik düzenleme araçları da geliştirildi. Yeni Light Adjustment Layer; pozlama, kontrast, gölgeler, parlak alanlar, beyazlar ve siyahlar üzerinde geri alınabilir ayarlamalar sunuyor. Dynamic Text özelliğiyle yazılar vektör yolları boyunca yerleştirilebiliyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Adobe, prompt tabanlı araçları profesyonel düzenleme akışının alternatifi olarak değil, yeni bir giriş yöntemi olarak konumlandırıyor. Kullanıcı hızlıca genel bir değişiklik isteyebiliyor, ardından katmanlar ve maskeler üzerinden ayrıntılı düzenlemeye devam edebiliyor.
|
||||
|
||||
Bu yaklaşım, AI araçlarının tasarımcıların kontrolünü tamamen ortadan kaldırması yerine tekrar eden işlemleri hızlandırmasını sağlayabilir.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Bence Photoshop’un doğru yaptığı şey, prompt sonucunu düzenlenebilir bir katman olarak bırakması. Tek bir komutla son görüntüyü üretip kullanıcıyı kilitlemek profesyonel tasarım akışında yeterli değil. Prompt hız sağlarken maske, katman ve klasik araçlar kontrolü tasarımcıda tutuyor. AI tasarımcıyı değiştirmekten çok editörün yeni bir kullanım biçimine dönüşüyor.
|
||||
|
||||
---
|
||||
|
||||
### Figma, vektör düzenlemeyi hızlandıran yeni araçlar ekledi
|
||||
|
||||
**Kaynaklar:** https://www.figma.com/release-notes/, https://releasebot.io/updates/figma
|
||||
|
||||
Figma, vektör düzenleme akışına doğrudan silme ve hızlı renklendirme özellikleri ekledi. Kullanıcılar artık vektör düzenleme modundayken çizgileri seçmek zorunda kalmadan silgi aracıyla kaldırabiliyor.
|
||||
|
||||
Yeni dolgu aracıyla bir renk veya gradient seçilerek imlecin geçtiği birden fazla kapalı alan aynı hareket içerisinde renklendirilebiliyor. Araç, vektör düzenleme ve Draw modunda `Shift + E` kısayoluyla açılıyor.
|
||||
|
||||
Büyük bir ürün lansmanı gibi görünmese de ikon, illüstrasyon ve küçük vektörel düzenlemelerde ihtiyaç duyulan adım sayısını azaltıyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Figma’nın temel gücü arayüz tasarımı olsa da ekipler ikon ve basit illüstrasyonlar için de aracı yoğun şekilde kullanıyor. Daha gelişmiş düzenlemeler için sürekli Illustrator gibi ayrı bir uygulamaya geçmek çalışma akışını yavaşlatabiliyor.
|
||||
|
||||
Yeni araçlar Figma’yı tam kapsamlı bir illüstrasyon yazılımına dönüştürmüyor ancak günlük vektör işlemlerini daha hızlı hâle getiriyor.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Bazen en kullanışlı güncellemeler büyük AI duyuruları değil, her gün yaptığımız küçük işlemleri hızlandıran özellikler oluyor. Figma’daki bu yenilikler de tam olarak böyle. Özellikle ikon setleri ve basit illüstrasyonlarla çalışan kişiler birkaç gereksiz adımı ortadan kaldırabilir. Küçük görünüyor ama düzenli kullanan biri için toplamda ciddi zaman kazandırabilir.
|
||||
|
||||
## Teknoloji haberleri
|
||||
|
||||
### Apple, 2 nanometrelik M6 işlemcisini ve M5 Ultra’yı tanıttı
|
||||
|
||||
**Kaynaklar:** https://www.apple.com/newsroom/2026/08/apple-introduces-m6-and-m5-ultra-for-a-big-leap-in-performance-and-ai-compute/, https://www.apple.com/newsroom/2026/08/apple-introduces-new-mac-studio-with-m5-max-and-m5-ultra/, https://www.reuters.com/business/retail-consumer/apple-launches-faster-mac-mini-mac-studio-tap-ai-boom-2026-08-25/, https://www.theverge.com/tech/984118/apple-m6-m5-ultra-chip-mac-mini-studio, https://news.ycombinator.com/item?id=49433292
|
||||
|
||||
Apple, şirketin 2 nanometre üretim sürecini kullanan ilk işlemcisi M6’yı tanıttı. İşlemci 12 çekirdekli CPU, 12 çekirdekli GPU, çift 16 çekirdekli Neural Engine, 170 GB/s bellek bant genişliği ve 32 GB’a kadar birleşik bellek desteği sunuyor.
|
||||
|
||||
M5 Ultra ise 36 çekirdeğe kadar CPU, 80 çekirdeğe kadar GPU, 32 çekirdekli Neural Engine ve 512 GB’a kadar birleşik bellek kapasitesiyle özellikle büyük yapay zekâ modelleri, video işleme ve profesyonel üretim işlerini hedefliyor.
|
||||
|
||||
Yeni Mac mini, M6 ve M5 Pro seçenekleriyle sunulurken Mac Studio tarafında M5 Max ve M5 Ultra seçenekleri bulunuyor. Ürünlerin ABD başlangıç fiyatları Mac mini için 899 dolar, Mac Studio için 2.499 dolar ve M5 Ultra modeli için 5.499 dolar olarak açıklandı.
|
||||
|
||||
Apple’ın performans karşılaştırmalarının şirket tarafından paylaşılan testlere dayandığını ve bağımsız test sonuçlarının ürünler satışa çıktıktan sonra netleşeceğini belirtmek gerekiyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Birleşik bellek mimarisi, büyük modellerin CPU ile GPU arasında veri kopyalanmadan çalıştırılmasına imkân tanıyor. 512 GB bellek seçeneği, normal ekran kartlarının kapasitesini aşan modellerin tek bir masaüstü sistemde çalıştırılabilmesi açısından dikkat çekici.
|
||||
|
||||
Bununla birlikte yüksek donanım fiyatları, bulut sistemi ile yerel çalışma istasyonu arasındaki maliyet karşılaştırmasını daha önemli hâle getiriyor.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
M5 Pro MacBook kullanan biri olarak benim en çok dikkatimi çeken bölüm, ham işlem gücünden ziyade birleşik bellek kapasitesi oldu. Büyük yerel modeller çalıştırmak istiyorsanız 512 GB gerçekten farklı bir sınıf oluşturuyor. Ancak bu fiyat seviyesinde cihazın kendi maliyetini ne kadar sürede çıkaracağını hesaplamak gerekiyor. Çoğu geliştirici için M6 Mac mini daha mantıklı görünürken M5 Ultra oldukça özel bir kullanıcı grubuna hitap ediyor.
|
||||
|
||||
---
|
||||
|
||||
### Samsung, yedi yıl güncelleme destekli Galaxy S26 FE’yi tanıttı
|
||||
|
||||
**Kaynaklar:** https://news.samsung.com/global/samsung-galaxy-s26-fe-delivering-the-latest-flagship-experience-focused-on-what-matters-most, https://www.theverge.com/report/985187/samsung-galaxy-s26-fe-hands-on-preview-specs-features-design
|
||||
|
||||
Samsung, Galaxy S26 ailesinin daha uygun fiyatlı modeli S26 FE’yi tanıttı. Telefon, Android 17 tabanlı One UI 9 ile kutudan çıkıyor ve yedi yıl işletim sistemi ile güvenlik güncellemesi desteği sunuyor.
|
||||
|
||||
Cihazda 120 Hz yenileme hızına sahip 6,7 inç AMOLED ekran, Exynos 2500 işlemci, 4.900 mAh batarya ve 45W kablolu şarj bulunuyor. Kamera sistemi 50 MP ana kamera, 12 MP ultra geniş açı, 3 kat optik yakınlaştırmalı 8 MP telefoto ve 12 MP ön kameradan oluşuyor.
|
||||
|
||||
Galaxy S26 FE’nin ABD başlangıç fiyatı 699,99 dolar olarak açıklandı. Donanım tarafındaki değişiklikler sınırlı kalırken Samsung, yeni yapay zekâ özelliklerini ve uzun yazılım desteğini ürünün temel avantajları olarak konumlandırıyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Akıllı telefonlarda işlemci ve kamera iyileştirmeleri giderek daha küçük adımlarla gerçekleşiyor. Bu nedenle güncelleme süresi, tamir edilebilirlik ve cihazın kaç yıl kullanılabileceği satın alma kararında daha belirleyici hâle geliyor.
|
||||
|
||||
Yedi yıllık destek olumlu olsa da FE serisinin fiyatı amiral gemisi modellere yaklaştıkça “uygun fiyatlı amiral gemisi” konumu tartışmalı hâle geliyor.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Uzun yazılım desteğini işlemci puanlarından daha değerli buluyorum. Telefon zaten günlük işlemleri rahatça yapıyorsa önemli olan birkaç yıl sonra da güvenli ve güncel kalabilmesi. Ancak FE modelinin fiyatı ana seriye fazla yaklaşıyorsa serinin varlık nedeni zayıflıyor. Kullanıcıların yalnızca başlangıç fiyatına değil, indirimlerle birlikte gerçek piyasa fiyatına bakması gerekecek.
|
||||
|
||||
---
|
||||
|
||||
### Yüzü aşkın teknoloji şirketi, AI destekli saldırılara karşı ortak siber savunma çağrısı yaptı
|
||||
|
||||
**Kaynaklar:** https://openai.com/collective-cyberdefense/, https://www.reuters.com/legal/litigation/major-tech-companies-call-defensive-surge-defeat-ai-driven-hacks-2026-08-27/, https://news.ycombinator.com/item?id=49467993
|
||||
|
||||
Yüzü aşkın teknoloji ve siber güvenlik şirketi, AI destekli siber saldırılara karşı ortak hareket edilmesini isteyen açık bir mektup yayımladı. İmzacılar arasında OpenAI, Anthropic, Microsoft, Alphabet ve Amazon gibi büyük şirketler bulunuyor.
|
||||
|
||||
Mektupta hastaneler, su sistemleri, enerji altyapısı ve internet hizmetleri gibi kritik sistemlerin daha güçlü şekilde korunması gerektiği belirtiliyor. Hükûmetlerden savunma çalışmalarına daha fazla kaynak ayırması ve güvenilir güvenlik araştırmacılarının gerekli araçlara kontrollü erişiminin kolaylaştırılması isteniyor.
|
||||
|
||||
Siber güvenlik şirketlerine tehdit verilerini daha hızlı paylaşma, AI laboratuvarlarına ise savunma araçlarını ve risk değerlendirmelerini sektörle paylaşma çağrısı yapılıyor.
|
||||
|
||||
**Neden önemli?**
|
||||
|
||||
Yapay zekâ, saldırganların açık araştırma, kimlik avı ve zararlı kod geliştirme süreçlerini hızlandırabilir. Aynı teknoloji güvenlik açıklarını bulmak ve saldırıları tespit etmek için savunma tarafında da kullanılabiliyor.
|
||||
|
||||
Sorun, savunma araçlarının ve kritik altyapı yatırımlarının saldırı kapasitesiyle aynı hızda gelişip gelişmeyeceği.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
Bu çağrıyı önemli buluyorum ama ortak mektup yayımlamak tek başına yeterli değil. Şirketlerin güvenlik araçlarını gerçekten paylaşması, tespit edilen açıkları hızlı kapatması ve ölçülebilir hedefler açıklaması gerekiyor. Burada ciddi bir risk olduğu kadar şirketlerin kendi ürünlerini konumlandırdığı ticari bir taraf da var. Bu yüzden verilen sözlerden çok, bundan sonra atılacak somut adımlara bakmak lazım.
|
||||
|
||||
## Bu hafta keşfettiğim açık kaynak proje
|
||||
|
||||
### Bu hafta keşfettiğim açık kaynak proje: OpenClaw
|
||||
|
||||
**GitHub bağlantısı:** https://github.com/openclaw/openclaw
|
||||
|
||||
**Resmî web sitesi:** https://openclaw.ai/
|
||||
|
||||
**Lisans:** MIT — https://github.com/openclaw/openclaw/blob/main/LICENSE
|
||||
|
||||
**Bu haftaki hareketlilik:**
|
||||
|
||||
OpenClaw’u bu haftaya dahil etme nedenim yeni bir kararlı sürüm değil. GitHub, 27 Ağustos’ta projenin ilk altı ayını, hızlı büyümesini ve güvenlik sürecini anlatan kapsamlı bir geliştirici yazısı yayımladı.
|
||||
|
||||
GitHub, OpenClaw’u platform tarihinin en hızlı büyüyen projelerinden biri olarak tanımlıyor. Yazıda projenin viral büyümesinin ardından bakım, güvenlik ve topluluk yönetimi tarafında yaşanan zorluklar ele alınıyor.
|
||||
|
||||
Kaynak: https://github.blog/open-source/maintainers/openclaw-went-viral-meet-the-maintainers-building-and-securing-it/
|
||||
|
||||
**Proje ne işe yarıyor?:**
|
||||
|
||||
OpenClaw, kendi cihazlarınızda veya sunucunuzda çalıştırabileceğiniz açık kaynaklı bir yapay zekâ asistanı. Farklı dil modellerini, araçları ve mesajlaşma kanallarını tek bir gateway üzerinden birbirine bağlayabiliyor.
|
||||
|
||||
Amaç, yalnızca tarayıcıda çalışan bir sohbet botu yerine kullandığınız kanallardan erişebileceğiniz ve kendi araçlarınızla işlem yapabilen kişisel bir ajan oluşturmak.
|
||||
|
||||
**Kimler kullanmalı?:**
|
||||
|
||||
Kendi AI asistanını oluşturmak isteyen geliştiriciler, self-host sistemlerle ilgilenenler, farklı model sağlayıcılarını tek yerde kullanmak isteyen ekipler ve tekrarlanan işlerini ajanlarla otomatikleştirmek isteyen teknik kullanıcılar değerlendirebilir.
|
||||
|
||||
Sisteme dosya, terminal veya mesajlaşma hesabı erişimi verilecekse güvenlik ve yetkilendirme konusunda deneyimsiz kullanıcıların kontrollü bir test ortamıyla başlaması daha doğru olur.
|
||||
|
||||
**Öne çıkan özellikleri:**
|
||||
|
||||
* Kendi cihazınızda veya sunucunuzda çalıştırılabilen gateway mimarisi
|
||||
* Farklı model sağlayıcılarını ve araçları aynı sistemde kullanabilme
|
||||
* Mesajlaşma kanallarını asistan arayüzüne dönüştürme
|
||||
* Skill, eklenti ve araçlarla genişletilebilme
|
||||
* Tek kullanıcı veya güvenilir küçük ekip senaryolarına uygun yapı
|
||||
* Verinin ve çalışma ortamının kullanıcı kontrolünde kalması
|
||||
|
||||
**Ben nasıl kullanabilirim?:**
|
||||
|
||||
OpenClaw’u ayrı bir sunucuda yalnızca okuma yetkileriyle çalıştırarak haftalık teknoloji gündemi araştırmam için kullanabilirim. Resmî blogların, changelog sayfalarının ve GitHub repository’lerinin bağlantılarını izleyip yeni gelişmeleri taslak hâlinde toplayabilir.
|
||||
|
||||
Ajan hiçbir platformda otomatik paylaşım yapmadan yalnızca aday haberleri, tarihleri ve kaynakları hazırlayabilir. Ben de son seçimi, doğrulamayı ve yorumlamayı kendim yapabilirim. Böylece araştırmanın tekrar eden bölümleri hızlanırken editoryal kontrol bende kalır.
|
||||
|
||||
**Düşüncem:**
|
||||
|
||||
OpenClaw’un en sevdiğim tarafı, asistanı sürekli yeni bir uygulamaya girerek kullanmak yerine zaten bulunduğumuz kanallara taşıması. Self-host edilebilmesi de kontrol açısından önemli. Ama böyle bir asistana dosya, mesaj ve terminal erişimi vermek ciddi bir güvenlik sorumluluğu oluşturuyor. Ben olsam önce izole bir ortamda, salt okunur yetkilerle ve hiçbir üretim parolası vermeden denerdim.
|
||||
|
||||
**Alternatif projeler:**
|
||||
|
||||
* Hermes Agent — https://github.com/NousResearch/hermes-agent
|
||||
* PydanticAI — https://github.com/pydantic/pydantic-ai
|
||||
|
||||
## Haftanın genel değerlendirmesi
|
||||
|
||||
Bu haftaki gelişmelerin ortak noktası bence “kontrol” oldu. OpenAI’ın güvenlik raporu ajanlara verilen yetkilerin ne kadar önemli olduğunu gösterirken Anthropic bu ajanları fiziksel cihazlara bağlamaya hazırlanıyor. Google ve Adobe ise yapay zekâ üretiminde yalnızca hız değil, kullanıcıya daha fazla yönlendirme ve düzenleme imkânı vermeye çalışıyor.
|
||||
|
||||
Yazılım ve donanım tarafında da aynı tabloyu görüyoruz. Kubernetes kaynakları daha kontrollü yönetmeye çalışıyor, GitLab self-host sistemlerin bakım sorumluluğunu hatırlatıyor, Apple ise daha büyük modelleri yerel cihazlarda çalıştırmayı hedefliyor. Yapay zekâ daha yetenekli hâle geldikçe asıl değer yalnızca modele sahip olmakta değil; modeli güvenli, sürdürülebilir ve gerçekten işe yarayan bir sistemin parçası hâline getirebilmekte olacak.
|
||||
@@ -6,7 +6,7 @@ readTime: "7 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "poyraz-ile-yazilima-dair-26072026"
|
||||
excerpt: "Bu hafta otonom yapay zeka güvenliğinden kamu kurumlarında üretken yapay zekaya, sıfır tıklama açıklarından Figma'nın tasarım araçlarındaki liderliğine kadar yoğun bir teknoloji gündemimiz var."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-26072026-cover.png"
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-26072026-cover.webp"
|
||||
lang: "tr"
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: "Software with Poyraz #0309082026"
|
||||
category: "Newsletter"
|
||||
date: "2026-08-09"
|
||||
readTime: "7 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "software-with-poyraz-0309082026"
|
||||
excerpt: "This week, we have a packed agenda, from AI cost competition and DeepMind's leadership shift to the 100x developer debate, Figma's file architecture updates, and changing power dynamics in tech."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-0309082026-cover.webp"
|
||||
lang: "en"
|
||||
---
|
||||
|
||||
# Software with Poyraz #0309082026
|
||||
|
||||
Hello from the week of August 3-9, 2026 :)
|
||||
|
||||
One common theme stands out in this week's technology agenda: as scale grows, costs, organizations, and power balances are being reshaped.
|
||||
|
||||
Chinese AI labs pushed pricing boundaries lower while Google DeepMind went through a historic leadership change. The software world debated the idea of the "100x developer" while security teams dealt with critical network vulnerabilities.
|
||||
|
||||
Figma changed its file architecture, tech layoffs passed last year's total before the year ended, and one of the biggest brands in the gaming industry changed hands through a 55 billion dollar transaction.
|
||||
|
||||
Let's look at the week's most important developments and what they tell us.
|
||||
|
||||
## Artificial Intelligence Developments
|
||||
|
||||
### Chinese AI Labs Push on Price and Performance
|
||||
|
||||
Source: Pakistan Today - Qwen3.8-Max and DeepSeek V4-Flash | Alibaba Cloud
|
||||
|
||||
Alibaba introduced Qwen3.8-Max, a 2.4 trillion parameter model. The model uses a Mixture of Experts architecture and activates only 95 billion of its total parameters for each request.
|
||||
|
||||
It is also reported to have a one million token context window and to have worked autonomously for 16 days on an internal software project.
|
||||
|
||||
Around the same period, DeepSeek announced V4-Flash pricing at 0.14 dollars per million input tokens and 0.28 dollars per million output tokens. According to reporting based on Artificial Analysis data, that price can be as low as one percent of some American competitors' costs.
|
||||
|
||||
The biggest shift in the AI race is no longer only about asking, "Who built the strongest model?" The question of who can offer the same reasoning capacity more cheaply is becoming just as decisive.
|
||||
|
||||
Lower model usage costs will open space for processing large document collections, running long-lived software agents, and building product ideas that previously did not make economic sense. But cheap tokens alone are not enough. Reliability, evaluation systems, data security, and model governance will become the new competitive fronts.
|
||||
|
||||
### A Historic Leadership Change at Google DeepMind
|
||||
|
||||
Source: The Guardian - Google DeepMind leadership change
|
||||
|
||||
Demis Hassabis, DeepMind's founder and CEO of 16 years, stepped away from daily operational responsibilities and moved into the roles of DeepMind Chair and Alphabet Chief Scientist. Operational leadership was handed over to CTO Koray Kavukcuoglu.
|
||||
|
||||
During the same period, longtime Google engineers Jeff Dean and Sanjay Ghemawat left the company to start a new venture called Discovery Loop, focused on machine learning, science, and engineering. Alphabet shares closed the day of the announcement down 4 percent.
|
||||
|
||||
Reading this only as a case of "brain drain" would be incomplete. Alphabet may be separating scientific vision from the operational needs of large-scale products such as Gemini by placing them into different leadership layers.
|
||||
|
||||
Still, the departure of names like Jeff Dean and Sanjay Ghemawat is an important signal. In the AI era, the biggest competitors of large companies will not only be other technology giants; they will also be the small, fast, research-focused teams that can emerge from within.
|
||||
|
||||
## Software Developments
|
||||
|
||||
### The Myth of the "100x Developer" in the AI Era
|
||||
|
||||
Source: Stack Overflow - Explorers, exploiters, and the myth of the 100x engineer
|
||||
|
||||
Stack Overflow's analysis describes developers who adopt AI tools early and achieve unusual productivity gains as "explorers." Most teams, however, prefer to use workflows that have already been tested and made reliable instead of discovering new methods themselves.
|
||||
|
||||
The core idea is that people seen as "100x developers" are not fundamentally different by birth. Curiosity, willingness to adapt, and freedom to experiment become more visible with AI. The job of leaders is not to find a few exceptional employees, but to turn their discoveries into methods the whole team can use.
|
||||
|
||||
The era in which code production speed alone was treated as a success metric is ending. A developer writing one hundred times more code does not mean the team is producing one hundred times more value.
|
||||
|
||||
The real issue is whether the code being produced is tested, secured, and shipped sustainably. The strongest engineering organizations of the future will not be the ones dependent on a few "super developers"; they will be the ones that can turn explorers' lessons into standards, evaluation systems, and automated quality gates.
|
||||
|
||||
### Cisco Releases a Critical IOS XE Security Update
|
||||
|
||||
Source: Cisco Security Advisory - IOS XE Security Hardening Release
|
||||
|
||||
Cisco released a critical security hardening update that fixes seven vulnerabilities found through internal testing in IOS XE software. The flaws include CVE-2026-20272, a vulnerability with a CVSS score of 9.8 that could allow unauthorized command execution.
|
||||
|
||||
Cisco says it has not found evidence that the vulnerabilities are being actively exploited. However, there is no workaround for the flaws; affected systems need to be moved to fixed software releases.
|
||||
|
||||
The habit of saying "let's leave the update for the next maintenance window" is becoming increasingly dangerous for critical infrastructure. Especially in devices at the center of the network, such as routers and switches, a single delay can affect the attack surface of an entire organization.
|
||||
|
||||
Patch management should no longer be an operation manually tracked only by IT teams. Inventory discovery, version control, risk prioritization, and staged rollout processes need to be automated as much as possible.
|
||||
|
||||
### Your MVP May Not Need a Kubernetes Cluster
|
||||
|
||||
Source: Stack Overflow - Your MVP doesn't need a Kubernetes cluster
|
||||
|
||||
In a Stack Overflow Podcast episode featuring Render CEO Anurag Goel, the discussion focused on why early-stage startups should not begin by managing Kubernetes and complex cloud infrastructure.
|
||||
|
||||
The message is quite clear: if a startup has not yet found product-market fit, spending limited engineering capacity on infrastructure management can slow down the actual product. Managed services offer a faster and more economical starting point for most MVPs.
|
||||
|
||||
One of the most expensive habits in software is trying to solve scale problems that do not exist yet. Designing a microservice architecture for millions of users when the product does not yet have one hundred users is often not technical preparation; it is delayed product development.
|
||||
|
||||
At the beginning, a simple application, a managed database, and a reliable deployment pipeline may be enough. Kubernetes is not a badge of success. It is a powerful but costly answer to needs that appear at a certain scale.
|
||||
|
||||
## Design Developments
|
||||
|
||||
### Figma Reorganizes File Architecture and AI Spending
|
||||
|
||||
Source: Figma - File management updates | Figma - Manage AI credits
|
||||
|
||||
As of August 3, Figma started renaming "Projects" to "Folders." On paid plans, folders can now be nested up to ten levels deep. Folder permissions are also being simplified so they can either inherit from the parent folder or be restricted to specific people.
|
||||
|
||||
The platform also gives administrators the ability to manage employees' access to paid AI credits. Admins can set full access, custom monthly limits, or disable access per user; they can also review credit requests and track usage amounts.
|
||||
|
||||
When these two updates are read together, the current direction of design tools becomes clearer. Enterprise design files are no longer simple visual documents; they increasingly resemble large software repositories with permissions, hierarchy, and dependencies.
|
||||
|
||||
Managing AI credits per user also shows that an "AI FinOps" mindset is entering design workflows. Teams will soon debate not only which design is better, but also which AI operation is worth its cost.
|
||||
|
||||
### AI Drift in Design Systems: Taking AI Out of the Loop at the Right Time
|
||||
|
||||
Source: TJ Pitre - Use AI to Need Less AI
|
||||
|
||||
An analysis by TJ Pitre, also highlighted in Smashing Magazine's weekly selection, focuses on the problem of "drift" created when AI interprets design systems. In this context, drift means the gap that can emerge between design and code.
|
||||
|
||||
The article argues that instead of leaving every check to AI again, design rules should be turned into machine-readable contracts. Color tokens, component properties, and allowed variants should be enforced by deterministic systems rather than reinterpreted by a model every time.
|
||||
|
||||
AI is strong at generating creative options, but it is still fragile in areas where the same rule must be followed exactly every time. In products that depend on pixel-level precision, even a small deviation can spread inconsistency across the whole component system.
|
||||
|
||||
The future role of designers will not only be writing good prompts. Deciding which decisions can be left to AI and which must be protected by fixed system rules will become a much more valuable skill.
|
||||
|
||||
## Technology News
|
||||
|
||||
### 2026 Tech Layoffs Have Already Passed Last Year's Total
|
||||
|
||||
Source: Fast Company - Tech layoffs August 2026 update
|
||||
|
||||
In the first week of August, Zillow announced layoffs affecting more than 500 people, TikTok 250, Etsy around 220, and Google 52.
|
||||
|
||||
According to Layoffs.fyi data, technology sector job losses in 2026 reached 125,759 as of August 6. That means the 122,606 total recorded across all of 2025 has already been passed before the end of the year.
|
||||
|
||||
Reading this only as "AI is taking people's jobs" is too simple. I think the bigger change is that company capital is being redirected. As organizations allocate billions of dollars to data centers, GPUs, and AI infrastructure, they are questioning operating expenses more aggressively.
|
||||
|
||||
This does not mean AI has nothing to do with job losses. The effect does not always appear as one employee being directly replaced by one model; investment budgets shifting from human labor to infrastructure is also part of the same transformation.
|
||||
|
||||
### A Saudi-Led Consortium Buys EA for 55 Billion Dollars
|
||||
|
||||
Source: SEPE - Saudi-led group completes $55bn purchase of EA
|
||||
|
||||
A consortium led by Saudi Arabia's Public Investment Fund completed the 55 billion dollar acquisition of Electronic Arts. The company behind brands such as The Sims, Battlefield, and EA Sports FC was taken private as a result of the transaction.
|
||||
|
||||
The acquisition is a leveraged deal that places a significant amount of debt on EA's balance sheet.
|
||||
|
||||
The gaming industry is no longer only a market that produces entertainment. It is a strategic power area that provides access to global culture, young audiences, and digital distribution channels.
|
||||
|
||||
Saudi Arabia's investments in gaming under Vision 2030 can be read not only as part of the transition away from an oil-based economy, but also as a long-term cultural influence strategy. The critical question from here is how the new ownership structure will affect EA's creative decisions and global brands.
|
||||
|
||||
### Defense Technology Cooperation Between Turkey, Saudi Arabia, and Pakistan
|
||||
|
||||
Source: ShiftDelete.Net - Mecca Agreement
|
||||
|
||||
Turkey, Saudi Arabia, and Pakistan signed a trilateral defense agreement in Mecca on August 7. In addition to treating an armed attack against one party as an attack against all parties, the agreement aims to develop shared defense technologies and enable military interoperability.
|
||||
|
||||
This level of integration is expected to require technical infrastructure such as common data link standards, compatible command-and-control software, and identification friend or foe systems. The localization of AKINCI in Saudi Arabia and the previously discussed joint investment option for KAAN also strengthen the technological foundation of the cooperation.
|
||||
|
||||
Modern defense alliances are built not only through signed documents, but also through software protocols. If two armies can securely share the same data and see the same operational picture, that can create a more lasting bond than political statements.
|
||||
|
||||
But interoperability also creates technological dependency. That is why who defines the standards, where the data is stored, and who owns the critical software will be at least as important as joint production.
|
||||
|
||||
## Open Source Radar of the Week
|
||||
|
||||
### Qwen3.8-27B: A New Candidate for Local AI
|
||||
|
||||
Source: LOG - Qwen3.8-Max and Qwen3.8-27B | Alibaba Cloud
|
||||
|
||||
Alibaba announced that, alongside Qwen3.8-Max, it will also publish the weights of the smaller Qwen3.8-27B model openly. The models are expected to be shared through Hugging Face and ModelScope.
|
||||
|
||||
There is an important detail here: as of August 9, Qwen3.8-27B is not yet available for download; the open weights are expected to be released the following week. Before the license terms are finalized, it is more accurate to describe the model as "open-weight" rather than fully "open source" in the technical sense.
|
||||
|
||||
The ability to run a strong model in the 27 billion parameter class inside a company or on local hardware creates serious value for data privacy, cost control, and provider independence.
|
||||
|
||||
With closed APIs, pricing, usage policy, or access conditions can change unilaterally. Open weights allow teams to evaluate and customize the model on their own infrastructure. Still, the model's real value will only become clear after release, once its license, hardware requirements, and independent benchmark results are visible.
|
||||
|
||||
## This Week's Bigger Picture
|
||||
|
||||
This week's news points to three major shifts.
|
||||
|
||||
First, AI capacity is getting cheaper quickly. Competition is no longer moving only through model quality, but also through usage cost and accessibility.
|
||||
|
||||
Second, software and design teams are seeing a power shift from production to verification. As producing code or interfaces becomes easier, reliability, testing, standards, and governance become more valuable.
|
||||
|
||||
Third, technology is increasingly becoming a geopolitical asset. From AI models to gaming companies, from data links to design tools, technological infrastructure sits at the center of economic and political power.
|
||||
|
||||
Which development do you think was the most important one this week?
|
||||
@@ -6,7 +6,7 @@ readTime: "6 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "software-with-poyraz-1319072026"
|
||||
excerpt: "This week, we have a packed tech agenda, from AI model price wars and enterprise agent protocols to HTTP QUERY, Figma updates, and Cloudflare's machine-economy push."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-1319072026-cover.png"
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-1319072026-cover.webp"
|
||||
lang: "en"
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
title: "Software with Poyraz #2430082026"
|
||||
category: "Newsletter"
|
||||
date: "2026-08-30"
|
||||
readTime: "13 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "software-with-poyraz-2430082026"
|
||||
excerpt: "This week, we cover rogue AI agents, systems that connect models to physical devices, Kubernetes 1.37, Apple's M6, and the growing importance of control across technology."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-2430082026-cover.webp"
|
||||
lang: "en"
|
||||
---
|
||||
|
||||
# Software with Poyraz #2430082026
|
||||
|
||||
Hello,
|
||||
|
||||
Welcome to a new issue of Software with Poyraz. It was an unusually busy week in technology, from AI agents crossing digital boundaries and beginning to control physical devices to a new Kubernetes release and Apple's M6 processor.
|
||||
|
||||
I have gathered the developments I found most meaningful for developers, designers, students, and anyone curious about technology.
|
||||
|
||||
## Artificial Intelligence Developments
|
||||
|
||||
### OpenAI Published a Detailed Report on AI Agents That Escaped Their Boundaries
|
||||
|
||||
**Sources:** https://openai.com/index/hugging-face-incident-and-the-road-ahead/, https://metr.org/blog/2026-08-26-openai-hugging-face-incident-investigation/, https://www.reuters.com/business/openai-report-says-its-network-was-hacked-by-its-own-rogue-ai-agents-2026-08-26/, https://news.ycombinator.com/item?id=49454314
|
||||
|
||||
OpenAI published a detailed technical report about an incident that took place during an internal cybersecurity test in July. The test used an internal model described as being comparable in scale to GPT-5.6 Sol, but with fewer safety restrictions.
|
||||
|
||||
Some agents tasked with finding security vulnerabilities moved beyond their assigned communication channels. They exploited weaknesses in shared infrastructure, gained internet access, and interacted with third-party systems without authorization. According to METR's independent review, exposed Hugging Face credentials were shared inside a common workspace used by the agents, after which hundreds of agents were directed toward malicious data uploads.
|
||||
|
||||
The new development this week was not the incident itself, but the technical investigations published by OpenAI and METR. The reports show how unexpected behavior can compound when large numbers of agents operate in the same infrastructure.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
Until now, AI safety discussions have mostly focused on harmful answers and misinformation. Once autonomous agents can access file systems, terminals, API keys, and the internet, model safety becomes infrastructure security.
|
||||
|
||||
For developers running agents on their own servers, permission boundaries, network access, secret storage, and detailed monitoring are no longer secondary concerns.
|
||||
|
||||
**My take:**
|
||||
|
||||
This incident suggests that before asking, “How smart is the model?” we should ask, “How much authority did we give it?” One agent making a mistake is one thing; hundreds of agents influencing one another through shared infrastructure is an entirely different risk. Good prompting is not enough when connecting agents to production systems. Least privilege, isolated environments, and comprehensive logging should become standard.
|
||||
|
||||
---
|
||||
|
||||
### Anthropic Announced the Model Hardware Standard for Connecting AI Agents to Physical Devices
|
||||
|
||||
**Sources:** https://www.anthropic.com/news/model-hardware-standard-research-preview, https://www.reuters.com/technology/anthropic-unveils-new-framework-allowing-ai-agents-operate-physical-devices-2026-08-27/, https://news.ycombinator.com/item?id=49468834
|
||||
|
||||
Anthropic released a research preview of the Model Hardware Standard, which aims to let AI agents communicate with physical devices through a secure, standardized interface. The project is being developed with the Janelia Research Campus at the Howard Hughes Medical Institute.
|
||||
|
||||
The standard is intended for programmable equipment such as microscopes, liquid-handling systems, robotic arms, and laser calibration tools. Anthropic says that a common interface could reduce integrations that currently take weeks or months to a matter of hours or minutes.
|
||||
|
||||
The system is model-independent. Hardware can be exposed through a shared definition, while agents access tools through standardized protocols similar to MCP. Anthropic plans to open-source the standard after safety testing and early partnerships.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
AI agents have so far operated mostly in browsers, code editors, and enterprise software. Once physical equipment enters the picture, an agent's mistake may affect a real machine or scientific experiment rather than merely creating the wrong file.
|
||||
|
||||
Authorization, emergency stops, and physical verification therefore become far more important.
|
||||
|
||||
**My take:**
|
||||
|
||||
Bringing MCP's common connection model from software into hardware makes sense. But in the physical world, “the agent made a mistake, so let's undo it” is not always an option. Safety layers must be standardized alongside speed. If implemented well, this could become a significant step for scientific research and manufacturing automation.
|
||||
|
||||
---
|
||||
|
||||
### Google Added More Control to Video Generation with Gemini Omni 1.1 Flash
|
||||
|
||||
**Sources:** https://blog.google/innovation-and-ai/technology/developers-tools/build-with-gemini-omni-1-1-flash/, https://deepmind.google/blog/gemini-omni-1-1-flash-lets-you-build-with-more-control/, https://ai.google.dev/gemini-api/docs/models/gemini-omni-flash, https://the-decoder.com/googles-gemini-omni-1-1-flash-makes-ai-video-generation-cheaper-and-more-flexible/, https://news.ycombinator.com/item?id=49467922
|
||||
|
||||
Google released Gemini Omni 1.1 Flash, a video generation and editing model for developers. Rather than focusing only on text-to-video generation, the model is designed to offer more control at different stages of production.
|
||||
|
||||
Scene extension can now use the preceding ten seconds as context instead of relying only on the final frame. Videos can be extended in ten-second segments to a total of 40 seconds. Users can also define separate opening and closing frames to produce a controlled transition between two images.
|
||||
|
||||
Developers can first create quicker, cheaper previews at 360p and then render the selected result in 4K. The model is available through Google AI Studio and the Gemini API.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
The main problem in AI video is no longer image quality alone. Characters, cameras, motion, and continuity can change with every attempt, making professional workflows difficult. Separating previews from final rendering can also reduce experimentation costs for creators and product teams.
|
||||
|
||||
**My take:**
|
||||
|
||||
The AI video race is moving beyond “Who produces the most realistic image?” What matters now is how precisely we can direct the result and preserve the same character across shots. Testing quickly at low resolution and rendering only the chosen result in 4K is a practical improvement. Real control mechanisms like these will matter more than flashy demos.
|
||||
|
||||
## Software Developments
|
||||
|
||||
### Kubernetes 1.37 “Garhwal” Was Released
|
||||
|
||||
**Sources:** https://kubernetes.io/blog/2026/08/26/kubernetes-v1-37-release/, https://www.sysdig.com/blog/kubernetes-1-37-new-security-features
|
||||
|
||||
Kubernetes 1.37 “Garhwal” arrived with 67 enhancements: 16 stable, 23 beta, and 27 alpha changes, along with one deprecation or removal.
|
||||
|
||||
One of the most notable improvements allows the Horizontal Pod Autoscaler to reduce workloads to zero pods based on selected external and object metrics. The capability reached beta and is enabled by default, allowing services that do not need to run continuously to stop consuming resources while idle.
|
||||
|
||||
The `metrics.k8s.io` API became stable after roughly nine years in beta. SELinuxMount also reached stable status, Dynamic Resource Allocation gained improvements for GPUs and specialized hardware, and pod checkpoint-and-restore was introduced in alpha.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
Idle resources create substantial costs in AI inference, batch processing, and event-driven services. Scaling to zero can reduce that waste, while dynamic resource allocation can help teams share scarce hardware such as GPUs more efficiently. It is also valuable for self-hosted environments where every resource matters.
|
||||
|
||||
**My take:**
|
||||
|
||||
Kubernetes can be much more complex than a project actually needs. But once a team manages many services and GPU workloads, these improvements begin to pay off. Scaling to zero can bring significant savings for AI services that are not used continuously. Still, each new capability should be weighed against its operational complexity instead of being adopted simply because it exists.
|
||||
|
||||
---
|
||||
|
||||
### GitLab Released a Critical Security Update for Self-Hosted Installations
|
||||
|
||||
**Sources:** https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-3-1-released/, https://nvd.nist.gov/vuln/detail/CVE-2026-77801
|
||||
|
||||
GitLab released security versions 19.3.1, 19.2.5, and 19.1.7 for Community Edition and Enterprise Edition. The company recommends updating internet-accessible, self-hosted GitLab installations as soon as possible.
|
||||
|
||||
CVE-2026-77801 allowed an authenticated user to perform a denial-of-service attack that could stop background job processing. The issue was caused by insufficient limits on the number of certain objects and received a CVSS score of 6.5.
|
||||
|
||||
GitLab.com has already been updated by the company, and GitLab Dedicated customers do not need to take separate action. Teams operating their own GitLab servers must install the update themselves.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
If background jobs stop, CI/CD runs, email notifications, repository updates, and other automations can be affected. A Git server being reachable does not mean the system is fully healthy. The patch is another reminder that self-hosting gives users control, but also makes maintenance their responsibility.
|
||||
|
||||
**My take:**
|
||||
|
||||
As I have become more interested in Gitea and self-hosted systems, this story stands out to me. Running your own Git server offers independence and control, but failing to track updates creates serious risk. Self-hosting is not just starting a Docker Compose stack and forgetting about it. Updates, backups, and monitoring must be treated as part of the installation.
|
||||
|
||||
---
|
||||
|
||||
### GitHub Classroom Was Shut Down Completely
|
||||
|
||||
**Sources:** https://github.blog/changelog/2026-08-27-github-classroom-deprecated/, https://github.com/orgs/community/discussions/205975, https://docs.github.com/en/education/manage-coursework-with-github-classroom/get-started-with-github-classroom/about-github-classroom
|
||||
|
||||
The GitHub Classroom website, APIs, and related services were fully disabled on August 28. GitHub had announced the decision earlier; this week's development was the service's final shutdown.
|
||||
|
||||
The closure does not affect normal GitHub accounts, organizations, or repositories. Student assignment repositories remain available. However, class names, assignment definitions, test settings stored outside repositories, and some LTI rosters kept inside Classroom are being permanently deleted.
|
||||
|
||||
GitHub is directing educators toward selected partners and alternative education tools. Institutions that built course workflows on the Classroom API now need to migrate to a different system.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
GitHub Classroom was widely used by universities to distribute assignments, run automated tests, and manage student repositories. Its closure shows that educators need to back up not only code but also workflow configuration. Export and migration options should be considered before automation is built around any platform.
|
||||
|
||||
**My take:**
|
||||
|
||||
We cannot assume a software service will exist forever simply because it is widely used. Preserving the repositories is good, but losing workflow metadata may be a serious problem for some educators. When building similar systems, we should ask who truly controls the data and whether it can be exported. Open standards and portable workflows matter once again.
|
||||
|
||||
## Design Developments
|
||||
|
||||
### Photoshop Brought Prompt-Based Editing into the Traditional Editor
|
||||
|
||||
**Sources:** https://blog.adobe.com/en/publish/2026/08/27/new-photoshop-innovations-bring-you-more-choice-control-at-every-stage-of-your-creative-process, https://www.theverge.com/tech/985491/adobe-photoshop-ai-assisted-editor-markup
|
||||
|
||||
Adobe added an optional beta workspace called AI Assisted Editor to Photoshop. Users can describe an edit in natural language and receive the result as a generative layer.
|
||||
|
||||
AI Markup lets users draw arrows, circles, or simple annotations directly onto an image to show what should change. Instruct Edit with Masks, powered by Firefly Image 5, aims to modify only the masked area while preserving the rest of the image.
|
||||
|
||||
Traditional tools were improved as well. The new Light Adjustment Layer provides reversible controls for exposure, contrast, shadows, highlights, whites, and blacks. Dynamic Text can place type along vector paths.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
Adobe is positioning prompts as a new input method within professional editing rather than as a replacement for the editing workflow. A user can request a broad change quickly, then continue refining the result with layers and masks. This approach can accelerate repetitive work without removing the designer's control.
|
||||
|
||||
**My take:**
|
||||
|
||||
The important decision is that Photoshop leaves the prompt result as an editable layer. Producing one final image from a command and locking the user into it is not enough for professional work. Prompts provide speed; masks, layers, and traditional tools keep control with the designer. AI is becoming a new way to use the editor rather than replacing the designer.
|
||||
|
||||
---
|
||||
|
||||
### Figma Added Tools That Speed Up Vector Editing
|
||||
|
||||
**Sources:** https://www.figma.com/release-notes/, https://releasebot.io/updates/figma
|
||||
|
||||
Figma introduced direct erasing and faster coloring to its vector editing workflow. Users can now remove paths with an eraser while in vector edit mode instead of selecting each line first.
|
||||
|
||||
The new fill tool allows a selected color or gradient to be applied across multiple closed regions in a single drag. It is available in vector editing and Draw mode through the `Shift + E` shortcut.
|
||||
|
||||
This may not look like a major product launch, but it reduces the number of steps required for icons, illustrations, and small vector adjustments.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
Figma's primary strength remains interface design, but teams also use it heavily for icons and simple illustrations. Constantly switching to a separate application such as Illustrator slows down the workflow. These additions do not turn Figma into a full illustration suite, but they make everyday vector work faster.
|
||||
|
||||
**My take:**
|
||||
|
||||
The most useful updates are sometimes not major AI announcements but small improvements to tasks we repeat every day. These tools fit that description. Removing a few unnecessary steps may look minor, but it can save significant time for people who regularly work on icon sets and simple illustrations.
|
||||
|
||||
## Technology News
|
||||
|
||||
### Apple Introduced the 2-Nanometer M6 and the M5 Ultra
|
||||
|
||||
**Sources:** https://www.apple.com/newsroom/2026/08/apple-introduces-m6-and-m5-ultra-for-a-big-leap-in-performance-and-ai-compute/, https://www.apple.com/newsroom/2026/08/apple-introduces-new-mac-studio-with-m5-max-and-m5-ultra/, https://www.reuters.com/business/retail-consumer/apple-launches-faster-mac-mini-mac-studio-tap-ai-boom-2026-08-25/, https://www.theverge.com/tech/984118/apple-m6-m5-ultra-chip-mac-mini-studio, https://news.ycombinator.com/item?id=49433292
|
||||
|
||||
Apple introduced the M6, its first processor built on a 2-nanometer manufacturing process. It includes a 12-core CPU, 12-core GPU, dual 16-core Neural Engines, 170 GB/s of memory bandwidth, and support for up to 32 GB of unified memory.
|
||||
|
||||
The M5 Ultra targets large AI models, video processing, and professional production with up to a 36-core CPU, an 80-core GPU, a 32-core Neural Engine, and as much as 512 GB of unified memory.
|
||||
|
||||
The new Mac mini is offered with M6 and M5 Pro options, while the Mac Studio comes with M5 Max and M5 Ultra. US starting prices are $899 for Mac mini, $2,499 for Mac Studio, and $5,499 for the M5 Ultra model. Apple's comparisons are based on its internal tests, so independent results will become clearer after the products ship.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
Unified memory allows large models to run without copying data between CPU and GPU memory. The 512 GB option could support models that exceed the capacity of conventional graphics cards on a single desktop system. At these prices, however, comparing local hardware with cloud costs becomes increasingly important.
|
||||
|
||||
**My take:**
|
||||
|
||||
As someone who uses an M5 Pro MacBook, the unified memory capacity caught my attention more than raw compute power. For large local models, 512 GB creates a different class of machine. Yet at this price, buyers need to calculate how long it will take the device to pay for itself. The M6 Mac mini will probably make sense for far more developers; the M5 Ultra is aimed at a very specialized group.
|
||||
|
||||
---
|
||||
|
||||
### Samsung Introduced the Galaxy S26 FE with Seven Years of Updates
|
||||
|
||||
**Sources:** https://news.samsung.com/global/samsung-galaxy-s26-fe-delivering-the-latest-flagship-experience-focused-on-what-matters-most, https://www.theverge.com/report/985187/samsung-galaxy-s26-fe-hands-on-preview-specs-features-design
|
||||
|
||||
Samsung announced the Galaxy S26 FE, the more affordable member of the S26 family. It ships with Android 17-based One UI 9 and promises seven years of operating system and security updates.
|
||||
|
||||
The phone includes a 6.7-inch 120 Hz AMOLED display, an Exynos 2500 processor, a 4,900 mAh battery, and 45W wired charging. Its cameras include a 50 MP main sensor, a 12 MP ultrawide, an 8 MP telephoto with 3x optical zoom, and a 12 MP front camera.
|
||||
|
||||
The Galaxy S26 FE starts at $699.99 in the United States. Hardware changes are limited, while Samsung is positioning new AI features and long-term software support as its main advantages.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
Processor and camera upgrades in smartphones are becoming increasingly incremental. Update duration, repairability, and useful lifespan therefore have a larger influence on purchase decisions. Seven years of support is positive, although the FE series' value becomes less clear as its price approaches the flagship range.
|
||||
|
||||
**My take:**
|
||||
|
||||
I value long software support more than benchmark scores. If a phone already handles everyday tasks well, remaining secure and current several years later matters more. But if the FE model's price gets too close to the main series, its purpose becomes weaker. Buyers should consider the actual retail price after discounts rather than the launch price alone.
|
||||
|
||||
---
|
||||
|
||||
### More Than One Hundred Technology Companies Called for Collective Cyber Defense
|
||||
|
||||
**Sources:** https://openai.com/collective-cyberdefense/, https://www.reuters.com/legal/litigation/major-tech-companies-call-defensive-surge-defeat-ai-driven-hacks-2026-08-27/, https://news.ycombinator.com/item?id=49467993
|
||||
|
||||
More than one hundred technology and cybersecurity companies published an open letter calling for collective action against AI-assisted cyberattacks. Signatories include major companies such as OpenAI, Anthropic, Microsoft, Alphabet, and Amazon.
|
||||
|
||||
The letter calls for stronger protection of critical systems, including hospitals, water systems, energy infrastructure, and internet services. Governments are asked to invest more in defense and make controlled access to necessary tools easier for trusted security researchers.
|
||||
|
||||
Cybersecurity companies are urged to share threat data faster, while AI laboratories are asked to share defensive tools and risk assessments with the industry.
|
||||
|
||||
**Why it matters**
|
||||
|
||||
AI can speed up vulnerability research, phishing, and malicious code development for attackers. The same technology can also help defenders find vulnerabilities and detect attacks. The question is whether defensive tools and critical infrastructure investment can advance at the same pace as offensive capabilities.
|
||||
|
||||
**My take:**
|
||||
|
||||
The call matters, but publishing a joint letter is not enough. Companies need to share tools in practice, patch vulnerabilities quickly, and publish measurable goals. There is a real risk here, but there is also a commercial side as companies position their own products. The concrete actions that follow will matter more than the promises.
|
||||
|
||||
## This Week's Open-Source Discovery
|
||||
|
||||
### OpenClaw
|
||||
|
||||
**GitHub:** https://github.com/openclaw/openclaw
|
||||
|
||||
**Official website:** https://openclaw.ai/
|
||||
|
||||
**License:** MIT — https://github.com/openclaw/openclaw/blob/main/LICENSE
|
||||
|
||||
**Why it stood out this week**
|
||||
|
||||
OpenClaw is included this week not because of a new stable release, but because GitHub published an in-depth developer story on August 27 about the project's first six months, rapid growth, and security journey.
|
||||
|
||||
GitHub describes OpenClaw as one of the fastest-growing projects in the platform's history. The article examines the maintenance, security, and community-management challenges that followed its viral growth.
|
||||
|
||||
Source: https://github.blog/open-source/maintainers/openclaw-went-viral-meet-the-maintainers-building-and-securing-it/
|
||||
|
||||
**What does it do?**
|
||||
|
||||
OpenClaw is an open-source AI assistant that can run on your own device or server. It connects different language models, tools, and messaging channels through a single gateway.
|
||||
|
||||
The goal is to create a personal agent that can be reached through the channels you already use and act through your own tools, rather than providing another chatbot confined to a browser tab.
|
||||
|
||||
**Who is it for?**
|
||||
|
||||
It may be useful for developers building their own AI assistant, people interested in self-hosted systems, teams that want to use multiple model providers in one place, and technical users automating repetitive tasks with agents.
|
||||
|
||||
If the system will receive access to files, terminals, or messaging accounts, less experienced users should begin in a controlled test environment with limited permissions.
|
||||
|
||||
**Highlights**
|
||||
|
||||
* A gateway architecture that runs on your own device or server
|
||||
* Support for multiple model providers and tools in one system
|
||||
* The ability to turn messaging channels into assistant interfaces
|
||||
* Extensibility through skills, plugins, and tools
|
||||
* A design suited to individuals or small, trusted teams
|
||||
* User control over data and the execution environment
|
||||
|
||||
**How could I use it?**
|
||||
|
||||
I could run OpenClaw on a separate server with read-only permissions to support my weekly technology research. It could monitor official blogs, changelogs, and GitHub repositories, then collect candidate stories with their dates and sources.
|
||||
|
||||
The agent would not publish anything automatically. It would only prepare research notes, while I would keep control over final selection, verification, and commentary. That would accelerate repetitive research without giving up editorial control.
|
||||
|
||||
**My take:**
|
||||
|
||||
My favorite part of OpenClaw is that it brings an assistant into the channels we already use instead of forcing us into yet another application. Self-hosting also offers valuable control. But granting an assistant access to files, messages, and a terminal creates serious security responsibilities. I would begin in an isolated environment, with read-only permissions and no production credentials.
|
||||
|
||||
**Alternatives:**
|
||||
|
||||
* Hermes Agent — https://github.com/NousResearch/hermes-agent
|
||||
* PydanticAI — https://github.com/pydantic/pydantic-ai
|
||||
|
||||
## The Week in Perspective
|
||||
|
||||
The common theme across this week's developments was control. OpenAI's security report showed how much the authority given to agents matters, while Anthropic is preparing to connect those agents to physical devices. Google and Adobe are trying to give users more direction and editing control in AI-assisted creation rather than focusing on speed alone.
|
||||
|
||||
The same pattern appears in software and hardware. Kubernetes is improving resource control, GitLab is reminding self-hosters of their maintenance responsibilities, and Apple is targeting larger models on local devices. As AI becomes more capable, the real value will not come from merely having access to a model. It will come from making that model part of a secure, sustainable system that solves a real problem.
|
||||
@@ -6,7 +6,7 @@ readTime: "7 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "software-with-poyraz-26072026"
|
||||
excerpt: "This week, we have a packed tech agenda, from autonomous AI safety and generative AI in public institutions to zero-click vulnerabilities and Figma's leadership in design tools."
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-26072026-cover.png"
|
||||
coverImage: "/blog/images/poyraz-ile-yazilima-dair-26072026-cover.webp"
|
||||
lang: "en"
|
||||
---
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ readTime: "5 min read"
|
||||
author: "Poyraz Avsever"
|
||||
slug: "vuejs-dokumantasyon-ceviri-projesi"
|
||||
excerpt: "Vue.js öğrenme sürecimde hissettiğim Türkçe kaynak eksikliğini gidermek için başlattığım topluluk çeviri projesine davetlisiniz."
|
||||
coverImage: "/blog/images/vuejs-ceviri-bolum1.png"
|
||||
coverImage: "/blog/images/vuejs-ceviri-bolum1.webp"
|
||||
---
|
||||
|
||||
# Vue.js Dokümantasyonunu Birlikte Türkçeye Çeviriyoruz!
|
||||
|
||||

|
||||

|
||||
|
||||
Merhaba,
|
||||
|
||||
@@ -25,7 +25,7 @@ Son zamanlarda kendimi frontend alanında geliştirmeye devam ediyorum ve rotam
|
||||
|
||||
## Peki Bunu Nasıl Düzenliyoruz?
|
||||
|
||||

|
||||

|
||||
|
||||
Açıkçası projeye ilk başladığımda "Bunu nasıl yöneteceğim?" sorusu kafamı biraz kurcaladı. Markdown dosyalarını tek tek kendi kendime çevirip commit atmak yerine, başkalarının da kolayca katılıp iş bölümü yapabileceği bir düzen kurmam gerekiyordu.
|
||||
|
||||
@@ -35,7 +35,7 @@ Yani dokümantasyondaki her sayfa, projemizde tamamlanmayı bekleyen bir görev
|
||||
|
||||
## Süreci Takip Etmek İçin Ne Yaptım?
|
||||
|
||||

|
||||

|
||||
|
||||
Issue açmak güzeldi ama "şu an kim ne yapıyor, hangi sayfa boşta?" sorularının yanıtı hala belirsizdi. Bu yüzden GitHub üzerinde bir **Kanban board** kurdum. Benim gibi projeler üretmeyi seven biriyseniz, o "Done" sütununun dolmaya başlamasının ne kadar motive edici olduğunu bilirsiniz.
|
||||
|
||||
@@ -49,7 +49,7 @@ Böylece kimse "acaba bu sayfayı çeviren var mı?" diye düşünmüyor. Her ş
|
||||
|
||||
## Nasıl Dahil Olabilirsiniz?
|
||||
|
||||

|
||||

|
||||
|
||||
Bu projeyi tek başıma yürütmemin çok uzun süreceğini söylemiştim. O yüzden eğer Vue.js öğreniyorsanız, İngilizce okuma pratiği yapmak istiyorsanız veya sadece açık kaynağa destek olmanın o güzel hissini yaşamak istiyorsanız sizi de bekliyoruz. Süreç inanın çok basit:
|
||||
|
||||
@@ -62,7 +62,7 @@ Gördüğünüz gibi aslında son derece standart bir açık kaynak katkı süre
|
||||
|
||||
## Çevirirken Dikkat Ettiğimiz Birkaç Şey
|
||||
|
||||

|
||||

|
||||
|
||||
Herkesin farklı bir çeviri tarzı olduğu için ortaya tutarlı bir Türkçe kaynak çıkması adına ufak tefek kurallarımız var elbette. Çok göz korkutucu şeyler değil; örneğin kod bloklarının içini çevirmemeye özen gösteriyoruz veya "component", "props", "render" gibi Türkçeye çevrildiğinde kafa karıştırabilecek teknik terimleri orijinal haliyle bırakmayı tercih ediyoruz.
|
||||
|
||||
@@ -70,7 +70,7 @@ Bütün bu detayları repomuzdaki `CONTRIBUTING.md` dosyasında derledik. Çevir
|
||||
|
||||
## Son Söz
|
||||
|
||||

|
||||

|
||||
|
||||
Eğer siz de benim gibi Vue.js dünyasını keşfediyorsanız veya halihazırda tecrübeli bir geliştiriciyseniz, bu projede herkese yetecek kadar sayfa var. Birlikte, ekosisteme değer katacak sağlam bir Türkçe kaynak bırakabiliriz.
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import "server-only";
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import matter from "gray-matter";
|
||||
|
||||
export type AnimationSourceLocale = "tr" | "en";
|
||||
|
||||
export type AnimationSource = {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
coverImage: string;
|
||||
platform: string;
|
||||
tools: string[];
|
||||
date: string;
|
||||
author: string;
|
||||
markdown: string;
|
||||
lang: AnimationSourceLocale;
|
||||
};
|
||||
|
||||
const ANIMATION_SOURCES_DIR = path.join(
|
||||
process.cwd(),
|
||||
"content",
|
||||
"animation-sources",
|
||||
);
|
||||
|
||||
function toSafeString(value: unknown, fallback = "") {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeFileSlug(fileName: string) {
|
||||
return fileName.replace(/\.(tr|en)\.md$/i, "").replace(/\.md$/i, "");
|
||||
}
|
||||
|
||||
function normalizeLocale(value: unknown): AnimationSourceLocale {
|
||||
return value === "en" ? "en" : "tr";
|
||||
}
|
||||
|
||||
function mapMarkdownToAnimationSource(
|
||||
fileName: string,
|
||||
raw: string,
|
||||
): AnimationSource {
|
||||
const parsed = matter(raw);
|
||||
const fallbackSlug = normalizeFileSlug(fileName);
|
||||
|
||||
return {
|
||||
slug: toSafeString(parsed.data.slug, fallbackSlug),
|
||||
title: toSafeString(parsed.data.title, fallbackSlug),
|
||||
excerpt: toSafeString(parsed.data.excerpt),
|
||||
coverImage: toSafeString(
|
||||
parsed.data.coverImage,
|
||||
"/media/cursor-portrait/poyraz-bottom-right-poster.webp",
|
||||
),
|
||||
platform: toSafeString(parsed.data.platform, "Web"),
|
||||
tools: toStringArray(parsed.data.tools),
|
||||
date: toSafeString(parsed.data.date),
|
||||
author: toSafeString(parsed.data.author, "Poyraz Avsever"),
|
||||
markdown: parsed.content.trim(),
|
||||
lang: normalizeLocale(parsed.data.lang),
|
||||
};
|
||||
}
|
||||
|
||||
function toTimestamp(value: string) {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
export async function listAnimationSources(
|
||||
locale?: string,
|
||||
): Promise<AnimationSource[]> {
|
||||
let files: string[];
|
||||
|
||||
try {
|
||||
files = await fs.readdir(ANIMATION_SOURCES_DIR);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sources = await Promise.all(
|
||||
files
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map(async (fileName) => {
|
||||
const raw = await fs.readFile(
|
||||
path.join(ANIMATION_SOURCES_DIR, fileName),
|
||||
"utf8",
|
||||
);
|
||||
return mapMarkdownToAnimationSource(fileName, raw);
|
||||
}),
|
||||
);
|
||||
|
||||
return sources
|
||||
.filter((source) => !locale || source.lang === locale)
|
||||
.sort((a, b) => {
|
||||
const dateDifference = toTimestamp(b.date) - toTimestamp(a.date);
|
||||
return dateDifference || a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAnimationSourceBySlug(
|
||||
slug: string,
|
||||
locale: string,
|
||||
): Promise<AnimationSource | null> {
|
||||
const safeSlug = slug.trim().toLowerCase();
|
||||
if (!safeSlug) return null;
|
||||
|
||||
const sources = await listAnimationSources(locale);
|
||||
return sources.find((source) => source.slug.toLowerCase() === safeSlug) ?? null;
|
||||
}
|
||||
+11
-2
@@ -62,12 +62,21 @@ export async function listBlogDetails(locale?: string): Promise<BlogDetail[]> {
|
||||
}),
|
||||
);
|
||||
|
||||
const targetLang = locale || "tr";
|
||||
return posts
|
||||
.filter((post) => post.lang === targetLang)
|
||||
.filter((post) => !locale || post.lang === locale)
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
export async function getBlogTranslations(post: BlogDetail) {
|
||||
const posts = await listBlogDetails();
|
||||
|
||||
return posts.filter(
|
||||
(candidate) =>
|
||||
candidate.coverImage === post.coverImage &&
|
||||
candidate.category.toLocaleLowerCase() === post.category.toLocaleLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBlogDetailBySlug(slug: string): Promise<BlogDetail | null> {
|
||||
const safeSlug = slug.trim().toLowerCase();
|
||||
if (!safeSlug) return null;
|
||||
|
||||
+70
-4
@@ -26,7 +26,15 @@ export type BlogPageData = {
|
||||
|
||||
const DEFAULT_IMAGE = "/news/design.svg";
|
||||
const DEFAULT_READ_TIME = "5 min";
|
||||
const BLOG_CATEGORIES = ["All", "Newsletter", "Frontend", "UX", "Software", "TypeScript", "Testing", "General"];
|
||||
const NEWSLETTER_CATEGORY = "Newsletter";
|
||||
const BLOG_CATEGORY_ORDER = [
|
||||
"Frontend",
|
||||
"UX",
|
||||
"Software",
|
||||
"TypeScript",
|
||||
"Testing",
|
||||
"General",
|
||||
];
|
||||
|
||||
function toTimestamp(value: string) {
|
||||
const timestamp = Date.parse(value);
|
||||
@@ -54,7 +62,11 @@ function normalizeCategory(value: string) {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export async function getAllBlogArticles(locale?: string): Promise<BlogArticleItem[]> {
|
||||
export function isNewsletterCategory(category: string) {
|
||||
return normalizeCategory(category) === normalizeCategory(NEWSLETTER_CATEGORY);
|
||||
}
|
||||
|
||||
async function getAllArticles(locale?: string): Promise<BlogArticleItem[]> {
|
||||
const posts = await listBlogDetails(locale);
|
||||
|
||||
const articles = posts.map((post) => ({
|
||||
@@ -66,13 +78,30 @@ export async function getAllBlogArticles(locale?: string): Promise<BlogArticleIt
|
||||
image: post.coverImage || DEFAULT_IMAGE,
|
||||
date: post.date,
|
||||
readTime: post.readTime || DEFAULT_READ_TIME,
|
||||
href: `/blog/${post.slug}`,
|
||||
href: isNewsletterCategory(post.category)
|
||||
? `/agenda/${post.slug}`
|
||||
: `/blog/${post.slug}`,
|
||||
author: post.author || "Poyraz Avsever",
|
||||
}));
|
||||
|
||||
return sortByDateDesc(articles);
|
||||
}
|
||||
|
||||
export async function getAllBlogArticles(locale?: string): Promise<BlogArticleItem[]> {
|
||||
const articles = await getAllArticles(locale);
|
||||
return articles.filter((article) => !isNewsletterCategory(article.category));
|
||||
}
|
||||
|
||||
export async function getAllAgendaArticles(locale?: string): Promise<BlogArticleItem[]> {
|
||||
const articles = await getAllArticles(locale);
|
||||
return articles.filter((article) => isNewsletterCategory(article.category));
|
||||
}
|
||||
|
||||
export async function getLatestAgendaArticle(locale?: string) {
|
||||
const articles = await getAllAgendaArticles(locale);
|
||||
return articles[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getHomeBlogNews(locale?: string, limit = 3) {
|
||||
const articles = await getAllBlogArticles(locale);
|
||||
|
||||
@@ -94,7 +123,14 @@ export async function getBlogPageData(
|
||||
searchQueryParam?: string,
|
||||
): Promise<BlogPageData> {
|
||||
const articles = await getAllBlogArticles(locale);
|
||||
const categories = BLOG_CATEGORIES;
|
||||
const availableCategories = new Set(articles.map((article) => article.category));
|
||||
const categories = [
|
||||
"All",
|
||||
...BLOG_CATEGORY_ORDER.filter((category) => availableCategories.has(category)),
|
||||
...[...availableCategories]
|
||||
.filter((category) => !BLOG_CATEGORY_ORDER.includes(category))
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
];
|
||||
const categoryByNormalized = new Map(
|
||||
categories.map((category) => [normalizeCategory(category), category]),
|
||||
);
|
||||
@@ -131,3 +167,33 @@ export async function getBlogPageData(
|
||||
currentPage,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgendaPageData(
|
||||
locale?: string,
|
||||
page = 1,
|
||||
pageSize = 12,
|
||||
searchQueryParam?: string,
|
||||
): Promise<BlogPageData> {
|
||||
const articles = await getAllAgendaArticles(locale);
|
||||
const searchQuery = (searchQueryParam ?? "").trim();
|
||||
const searchLower = searchQuery.toLocaleLowerCase();
|
||||
const filtered = searchLower
|
||||
? articles.filter(
|
||||
(item) =>
|
||||
item.title.toLocaleLowerCase().includes(searchLower) ||
|
||||
item.excerpt.toLocaleLowerCase().includes(searchLower),
|
||||
)
|
||||
: articles;
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(filtered.length, 1) / pageSize));
|
||||
const currentPage = Math.min(Math.max(1, page), totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
|
||||
return {
|
||||
articles: filtered.slice(start, start + pageSize),
|
||||
categories: ["All"],
|
||||
selectedCategory: "All",
|
||||
searchQuery,
|
||||
totalPages,
|
||||
currentPage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const certificates = [
|
||||
tr: "Scrimba üzerindeki JavaScript'in Zor Kısımları sertifikasıyla scope, closure, hoisting ve asenkron akış gibi temel JavaScript kavramlarını daha derinlemesine pekiştirdim.",
|
||||
en: "With this Scrimba Tricky Parts of JavaScript certificate, I deepened my understanding of core JavaScript concepts such as scope, closures, hoisting, and asynchronous flow.",
|
||||
},
|
||||
image: "/certificates/trickyPartsOfJavascriptScrimba.png",
|
||||
image: "/certificates/trickyPartsOfJavascriptScrimba.webp",
|
||||
category: "web",
|
||||
},
|
||||
{
|
||||
@@ -30,7 +30,7 @@ export const certificates = [
|
||||
tr: "Scrimba üzerindeki JavaScript sertifikasıyla temel programlama becerilerimi geliştirdim.",
|
||||
en: "With this Scrimba JavaScript certificate, I improved my foundational programming skills.",
|
||||
},
|
||||
image: "/certificates/javascriptScrimba.png",
|
||||
image: "/certificates/javascriptScrimba.webp",
|
||||
category: "web",
|
||||
},
|
||||
{
|
||||
@@ -47,7 +47,7 @@ export const certificates = [
|
||||
tr: "Scrimba üzerindeki CSS Animasyonları sertifikasıyla geçişler, hareket ve etkileşimli arayüz animasyonları oluşturma becerilerimi geliştirdim.",
|
||||
en: "With this Scrimba CSS Animations certificate, I improved my skills in transitions, motion, and interactive interface animations.",
|
||||
},
|
||||
image: "/certificates/cssAnimationsScrimba.png",
|
||||
image: "/certificates/cssAnimationsScrimba.webp",
|
||||
category: "web",
|
||||
},
|
||||
{
|
||||
@@ -64,7 +64,7 @@ export const certificates = [
|
||||
tr: "Scrimba üzerindeki CSS Grid sertifikasıyla modern ve duyarlı yerleşim sistemleri kurma becerilerimi geliştirdim.",
|
||||
en: "With this Scrimba CSS Grid certificate, I improved my ability to build modern and responsive layout systems.",
|
||||
},
|
||||
image: "/certificates/cssGridScrimba.png",
|
||||
image: "/certificates/cssGridScrimba.webp",
|
||||
category: "web",
|
||||
},
|
||||
{
|
||||
@@ -81,7 +81,7 @@ export const certificates = [
|
||||
tr: "Scrimba üzerindeki HTML ve CSS sertifikasıyla semantik yapı kurma ve arayüz stil verme konularında temel yetkinlik kazandım.",
|
||||
en: "With this Scrimba HTML and CSS certificate, I gained foundational skills in semantic structure and interface styling.",
|
||||
},
|
||||
image: "/certificates/htmlCssScrimba.png",
|
||||
image: "/certificates/htmlCssScrimba.webp",
|
||||
category: "web",
|
||||
},
|
||||
{
|
||||
@@ -217,7 +217,7 @@ export const certificates = [
|
||||
tr: "Git ve GitHub üzerine aldığım bu eğitimde sürüm kontrol süreçleri, proje yönetimi ve ekip çalışmasında değişiklik takibi konularında yetkinliğimi artırdım.",
|
||||
en: "In this training on Git and GitHub, I increased my competence in version control processes, project management, and tracking changes in teamwork.",
|
||||
},
|
||||
image: "/certificates/git.png",
|
||||
image: "/certificates/git.webp",
|
||||
category: "yazılım",
|
||||
},
|
||||
{
|
||||
@@ -251,7 +251,7 @@ export const certificates = [
|
||||
tr: "Profesyonel profil oluşturma, networking stratejileri geliştirme ve LinkedIn'i daha verimli kullanma konularında bilgi kazandım.",
|
||||
en: "I gained knowledge about creating a professional profile, developing networking strategies, and using LinkedIn more efficiently.",
|
||||
},
|
||||
image: "/certificates/linkedin.png",
|
||||
image: "/certificates/linkedin.webp",
|
||||
category: "kişisel",
|
||||
},
|
||||
{
|
||||
|
||||
+15
-15
@@ -1,18 +1,18 @@
|
||||
// Galeri resimleri – public/gallery/ klasörüne eklenen dosyaların yolları
|
||||
export const GALLERY_IMAGES: string[] = [
|
||||
"/gallery/1.jpg",
|
||||
"/gallery/2.jpg",
|
||||
"/gallery/3.jpg",
|
||||
"/gallery/4.jpg",
|
||||
"/gallery/5.jpg",
|
||||
"/gallery/6.jpg",
|
||||
"/gallery/7.jpeg",
|
||||
"/gallery/8.jpeg",
|
||||
"/gallery/9.jpeg",
|
||||
"/gallery/10.jpeg",
|
||||
"/gallery/11.jpeg",
|
||||
"/gallery/12.jpeg",
|
||||
"/gallery/13.jpeg",
|
||||
"/gallery/14.png",
|
||||
"/gallery/15.png",
|
||||
"/gallery/1.webp",
|
||||
"/gallery/2.webp",
|
||||
"/gallery/3.webp",
|
||||
"/gallery/4.webp",
|
||||
"/gallery/5.webp",
|
||||
"/gallery/6.webp",
|
||||
"/gallery/7.webp",
|
||||
"/gallery/8.webp",
|
||||
"/gallery/9.webp",
|
||||
"/gallery/10.webp",
|
||||
"/gallery/11.webp",
|
||||
"/gallery/12.webp",
|
||||
"/gallery/13.webp",
|
||||
"/gallery/14.webp",
|
||||
"/gallery/15.webp",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Localized } from "@/lib/locale";
|
||||
|
||||
export type LayoutPromoCopyKey =
|
||||
| "weeklyEyebrow"
|
||||
| "weeklyTitle"
|
||||
| "weeklyFallback"
|
||||
| "weeklyCta"
|
||||
| "projectsTitle"
|
||||
| "projectsDescription"
|
||||
| "projectsCta"
|
||||
| "latestPostEyebrow"
|
||||
| "latestPostTitle"
|
||||
| "latestPostFallback"
|
||||
| "latestPostCta"
|
||||
| "anatomyTitle"
|
||||
| "anatomyDescription"
|
||||
| "anatomyCta"
|
||||
| "youtubeEyebrow"
|
||||
| "youtubeTitle"
|
||||
| "youtubeDescription"
|
||||
| "youtubeCta"
|
||||
| "designSystemEyebrow"
|
||||
| "designSystemTitle"
|
||||
| "designSystemDescription"
|
||||
| "designSystemCta"
|
||||
| "communityEyebrow"
|
||||
| "communityTitle"
|
||||
| "communityDescription"
|
||||
| "communityCta"
|
||||
| "referencesEyebrow"
|
||||
| "referencesTitle"
|
||||
| "referencesDescription"
|
||||
| "referencesCta"
|
||||
| "sponsorsEyebrow"
|
||||
| "sponsorsTitle"
|
||||
| "sponsorsDescription"
|
||||
| "sponsorsCta"
|
||||
| "contactTitle"
|
||||
| "contactDescription"
|
||||
| "contactCta"
|
||||
| "linkedinEyebrow"
|
||||
| "linkedinTitle"
|
||||
| "linkedinDescription"
|
||||
| "linkedinCta"
|
||||
| "instagramEyebrow"
|
||||
| "instagramTitle"
|
||||
| "instagramDescription"
|
||||
| "instagramCta";
|
||||
|
||||
export type LayoutPromoCardDefinition = {
|
||||
id: string;
|
||||
kind?: "standard" | "sponsors";
|
||||
eyebrowKey?: LayoutPromoCopyKey;
|
||||
titleKey: LayoutPromoCopyKey;
|
||||
descriptionKey: LayoutPromoCopyKey;
|
||||
ctaKey: LayoutPromoCopyKey;
|
||||
href: string | Localized;
|
||||
icon: string;
|
||||
iconSurface?: "accent" | "foreground" | "primary";
|
||||
surface?: "default" | "primary";
|
||||
buttonVariant?: "default" | "outline" | "secondary";
|
||||
external?: boolean;
|
||||
contentSource?: "latestAgenda" | "latestPost";
|
||||
};
|
||||
|
||||
export type LayoutPromoSlide = readonly LayoutPromoCardDefinition[];
|
||||
|
||||
export const LEFT_LAYOUT_PROMO_SLIDES: readonly LayoutPromoSlide[] = [
|
||||
[
|
||||
{
|
||||
id: "weekly-agenda",
|
||||
eyebrowKey: "weeklyEyebrow",
|
||||
titleKey: "weeklyTitle",
|
||||
descriptionKey: "weeklyFallback",
|
||||
ctaKey: "weeklyCta",
|
||||
href: "/agenda",
|
||||
icon: "mdi:newspaper-variant-outline",
|
||||
surface: "primary",
|
||||
buttonVariant: "default",
|
||||
contentSource: "latestAgenda",
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
titleKey: "projectsTitle",
|
||||
descriptionKey: "projectsDescription",
|
||||
ctaKey: "projectsCta",
|
||||
href: "/projects",
|
||||
icon: "mdi:layers-triple-outline",
|
||||
},
|
||||
{
|
||||
id: "latest-post",
|
||||
eyebrowKey: "latestPostEyebrow",
|
||||
titleKey: "latestPostTitle",
|
||||
descriptionKey: "latestPostFallback",
|
||||
ctaKey: "latestPostCta",
|
||||
href: "/blog",
|
||||
icon: "mdi:post-outline",
|
||||
contentSource: "latestPost",
|
||||
},
|
||||
{
|
||||
id: "javascript-anatomy",
|
||||
titleKey: "anatomyTitle",
|
||||
descriptionKey: "anatomyDescription",
|
||||
ctaKey: "anatomyCta",
|
||||
href: "/content",
|
||||
icon: "ri:twitter-x-fill",
|
||||
iconSurface: "foreground",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "youtube",
|
||||
eyebrowKey: "youtubeEyebrow",
|
||||
titleKey: "youtubeTitle",
|
||||
descriptionKey: "youtubeDescription",
|
||||
ctaKey: "youtubeCta",
|
||||
href: "https://youtube.com/@poyrazavsever",
|
||||
icon: "mdi:youtube",
|
||||
iconSurface: "primary",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "poyraz-ui",
|
||||
eyebrowKey: "designSystemEyebrow",
|
||||
titleKey: "designSystemTitle",
|
||||
descriptionKey: "designSystemDescription",
|
||||
ctaKey: "designSystemCta",
|
||||
href: "https://ui.poyrazavsever.com",
|
||||
icon: "mdi:palette-swatch-outline",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "community",
|
||||
eyebrowKey: "communityEyebrow",
|
||||
titleKey: "communityTitle",
|
||||
descriptionKey: "communityDescription",
|
||||
ctaKey: "communityCta",
|
||||
href: "/about/volunteer-community",
|
||||
icon: "mdi:account-group-outline",
|
||||
},
|
||||
{
|
||||
id: "references",
|
||||
eyebrowKey: "referencesEyebrow",
|
||||
titleKey: "referencesTitle",
|
||||
descriptionKey: "referencesDescription",
|
||||
ctaKey: "referencesCta",
|
||||
href: "/about/references",
|
||||
icon: "mdi:comment-quote-outline",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export const RIGHT_LAYOUT_PROMO_SLIDES: readonly LayoutPromoSlide[] = [
|
||||
[
|
||||
{
|
||||
id: "sponsors",
|
||||
kind: "sponsors",
|
||||
eyebrowKey: "sponsorsEyebrow",
|
||||
titleKey: "sponsorsTitle",
|
||||
descriptionKey: "sponsorsDescription",
|
||||
ctaKey: "sponsorsCta",
|
||||
href: "/media-kit",
|
||||
icon: "mdi:handshake-outline",
|
||||
buttonVariant: "default",
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
titleKey: "contactTitle",
|
||||
descriptionKey: "contactDescription",
|
||||
ctaKey: "contactCta",
|
||||
href: "/contact",
|
||||
icon: "mdi:message-text-outline",
|
||||
iconSurface: "primary",
|
||||
surface: "primary",
|
||||
buttonVariant: "default",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "linkedin",
|
||||
eyebrowKey: "linkedinEyebrow",
|
||||
titleKey: "linkedinTitle",
|
||||
descriptionKey: "linkedinDescription",
|
||||
ctaKey: "linkedinCta",
|
||||
href: "https://www.linkedin.com/in/poyrazavsever/",
|
||||
icon: "mdi:linkedin",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "instagram",
|
||||
eyebrowKey: "instagramEyebrow",
|
||||
titleKey: "instagramTitle",
|
||||
descriptionKey: "instagramDescription",
|
||||
ctaKey: "instagramCta",
|
||||
href: "https://instagram.com/poyraz_avsever",
|
||||
icon: "mdi:instagram",
|
||||
surface: "primary",
|
||||
buttonVariant: "default",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
];
|
||||
+79
-64
@@ -14,28 +14,18 @@ export type MediaKitAudienceRow = {
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type MediaKitBreakdownRow = {
|
||||
id: string;
|
||||
label: Record<MediaKitLocale, string>;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export const MEDIA_KIT_PERIOD = {
|
||||
tr: "20 Haziran – 17 Temmuz 2026",
|
||||
en: "June 20 – July 17, 2026",
|
||||
tr: "Ömür boyu",
|
||||
en: "Lifetime",
|
||||
} satisfies Record<MediaKitLocale, string>;
|
||||
|
||||
export const MEDIA_KIT_METRICS: MediaKitMetric[] = [
|
||||
{
|
||||
id: "subscribers",
|
||||
label: {
|
||||
tr: "YouTube abonesi",
|
||||
en: "YouTube subscribers",
|
||||
},
|
||||
value: {
|
||||
tr: "8 B+",
|
||||
en: "8K+",
|
||||
},
|
||||
detail: {
|
||||
tr: "Son 28 günde +715",
|
||||
en: "+715 in the last 28 days",
|
||||
},
|
||||
icon: "mdi:account-multiple-outline",
|
||||
},
|
||||
{
|
||||
id: "views",
|
||||
label: {
|
||||
@@ -43,12 +33,12 @@ export const MEDIA_KIT_METRICS: MediaKitMetric[] = [
|
||||
en: "Views",
|
||||
},
|
||||
value: {
|
||||
tr: "31,1 B",
|
||||
en: "31.1K",
|
||||
tr: "788.422",
|
||||
en: "788,422",
|
||||
},
|
||||
detail: {
|
||||
tr: "Son 28 gün",
|
||||
en: "Last 28 days",
|
||||
tr: "Ömür boyu",
|
||||
en: "Lifetime",
|
||||
},
|
||||
icon: "mdi:play-circle-outline",
|
||||
},
|
||||
@@ -59,72 +49,97 @@ export const MEDIA_KIT_METRICS: MediaKitMetric[] = [
|
||||
en: "Watch time",
|
||||
},
|
||||
value: {
|
||||
tr: "2,0 B saat",
|
||||
en: "2.0K hours",
|
||||
tr: "16,5 B saat",
|
||||
en: "16.5K hours",
|
||||
},
|
||||
detail: {
|
||||
tr: "≈ 3:52 / görüntüleme",
|
||||
en: "≈ 3:52 per view",
|
||||
tr: "Ömür boyu",
|
||||
en: "Lifetime",
|
||||
},
|
||||
icon: "mdi:clock-outline",
|
||||
},
|
||||
{
|
||||
id: "monthly-audience",
|
||||
id: "subscribers",
|
||||
label: {
|
||||
tr: "Aylık kitle",
|
||||
en: "Monthly audience",
|
||||
tr: "Aboneler",
|
||||
en: "Subscribers",
|
||||
},
|
||||
value: {
|
||||
tr: "15,3 B",
|
||||
en: "15.3K",
|
||||
tr: "+9,3 B",
|
||||
en: "+9.3K",
|
||||
},
|
||||
detail: {
|
||||
tr: "Aktif izleyici",
|
||||
en: "Active viewers",
|
||||
tr: "Ömür boyu kazanılan",
|
||||
en: "Gained lifetime",
|
||||
},
|
||||
icon: "mdi:chart-line",
|
||||
icon: "mdi:account-multiple-outline",
|
||||
},
|
||||
{
|
||||
id: "retention",
|
||||
label: {
|
||||
tr: "İzlemeye devam edenler",
|
||||
en: "Stayed to watch",
|
||||
},
|
||||
value: {
|
||||
tr: "%59,7",
|
||||
en: "59.7%",
|
||||
},
|
||||
detail: {
|
||||
tr: "%40,3 izlemeden geçti",
|
||||
en: "40.3% swiped away",
|
||||
},
|
||||
icon: "mdi:eye-check-outline",
|
||||
},
|
||||
];
|
||||
|
||||
export const MEDIA_KIT_CONTENT_BREAKDOWN = [
|
||||
export const MEDIA_KIT_DEVICES: MediaKitBreakdownRow[] = [
|
||||
{
|
||||
id: "videos",
|
||||
label: {
|
||||
tr: "Uzun videolar",
|
||||
en: "Long-form videos",
|
||||
},
|
||||
value: {
|
||||
tr: "24,3 B",
|
||||
en: "24.3K",
|
||||
},
|
||||
percentage: 78.4,
|
||||
id: "computer",
|
||||
label: { tr: "Bilgisayar", en: "Computer" },
|
||||
value: 45.2,
|
||||
},
|
||||
{
|
||||
id: "shorts",
|
||||
label: {
|
||||
tr: "Shorts",
|
||||
en: "Shorts",
|
||||
},
|
||||
value: {
|
||||
tr: "6,7 B",
|
||||
en: "6.7K",
|
||||
},
|
||||
percentage: 21.6,
|
||||
id: "mobile",
|
||||
label: { tr: "Cep telefonu", en: "Mobile phone" },
|
||||
value: 38.8,
|
||||
},
|
||||
] as const;
|
||||
{ id: "tv", label: { tr: "TV", en: "TV" }, value: 11.2 },
|
||||
{ id: "tablet", label: { tr: "Tablet", en: "Tablet" }, value: 4.7 },
|
||||
];
|
||||
|
||||
export const MEDIA_KIT_LOCATIONS: MediaKitBreakdownRow[] = [
|
||||
{ id: "turkey", label: { tr: "Türkiye", en: "Türkiye" }, value: 89.2 },
|
||||
{
|
||||
id: "azerbaijan",
|
||||
label: { tr: "Azerbaycan", en: "Azerbaijan" },
|
||||
value: 3,
|
||||
},
|
||||
{ id: "germany", label: { tr: "Almanya", en: "Germany" }, value: 1.1 },
|
||||
{
|
||||
id: "united-states",
|
||||
label: { tr: "Amerika Birleşik Devletleri", en: "United States" },
|
||||
value: 0.2,
|
||||
},
|
||||
{
|
||||
id: "netherlands",
|
||||
label: { tr: "Hollanda", en: "Netherlands" },
|
||||
value: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
export const MEDIA_KIT_GENDER: MediaKitAudienceRow[] = [
|
||||
{ id: "male", label: "Erkek", value: 98 },
|
||||
{ id: "female", label: "Kadın", value: 2 },
|
||||
{ id: "female", label: "Kadın", value: 5.7 },
|
||||
{ id: "male", label: "Erkek", value: 94.3 },
|
||||
];
|
||||
|
||||
export const MEDIA_KIT_AGES: MediaKitAudienceRow[] = [
|
||||
{ id: "18-24", label: "18–24", value: 21.9 },
|
||||
{ id: "25-34", label: "25–34", value: 46.1 },
|
||||
{ id: "35-44", label: "35–44", value: 20.8 },
|
||||
{ id: "45-54", label: "45–54", value: 9.8 },
|
||||
{ id: "55-64", label: "55–64", value: 1.2 },
|
||||
{ id: "65+", label: "65+", value: 0.1 },
|
||||
{ id: "13-17", label: "13–17", value: 1.5 },
|
||||
{ id: "18-24", label: "18–24", value: 23.7 },
|
||||
{ id: "25-34", label: "25–34", value: 45.4 },
|
||||
{ id: "35-44", label: "35–44", value: 19.1 },
|
||||
{ id: "45-54", label: "45–54", value: 8.5 },
|
||||
{ id: "55-64", label: "55–64", value: 1.5 },
|
||||
{ id: "65+", label: "65+", value: 0.3 },
|
||||
];
|
||||
|
||||
export const MEDIA_KIT_TOPICS = [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+173
-18
@@ -1,16 +1,25 @@
|
||||
export type ProjectItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
title: {
|
||||
tr: string;
|
||||
en: string;
|
||||
} | string;
|
||||
description: {
|
||||
tr: string;
|
||||
en: string;
|
||||
};
|
||||
image: string;
|
||||
technologies: string[];
|
||||
architecture: {
|
||||
tr: string;
|
||||
en: string;
|
||||
};
|
||||
badge?: {
|
||||
tr: string;
|
||||
en: string;
|
||||
} | string;
|
||||
href?: string;
|
||||
caseStudySlug?: string;
|
||||
};
|
||||
|
||||
export const MOBILE_APPS: ProjectItem[] = [
|
||||
@@ -19,15 +28,103 @@ export const MOBILE_APPS: ProjectItem[] = [
|
||||
title: "Targiz App",
|
||||
badge: "Agritech",
|
||||
image: "/projects/targiz.png",
|
||||
technologies: [
|
||||
"React Native",
|
||||
"Next.js",
|
||||
"Express.js",
|
||||
"Supabase",
|
||||
"Tailwind CSS",
|
||||
],
|
||||
architecture: {
|
||||
tr: "React Native ve Next.js istemcilerini; Express.js servisleri, Supabase veri katmanı ve yapay zekâ destekli modüllerle birleştiren çapraz platform mimarisi.",
|
||||
en: "A cross-platform architecture combining React Native and Next.js clients with Express.js services, a Supabase data layer, and AI-assisted modules.",
|
||||
},
|
||||
href: "https://targiz.com",
|
||||
caseStudySlug: "targiz",
|
||||
description: {
|
||||
tr: "Ottoqua ekibiyle birlikte geliştirdiğimiz, küçük ölçekli üreticilere sahada destek veren yapay zeka destekli tarım uygulaması.",
|
||||
en: "An AI-powered agricultural application we developed with the Ottoqua team to support small-scale producers in the field.",
|
||||
tr: "Üç kişilik ekipte yazılım liderliği ve full-stack geliştirme sorumluluğunu üstlendiğim; üreticilere hastalık tespiti, lojistik ve pazar erişimi sunan mobil öncelikli tarım platformu.",
|
||||
en: "A mobile-first agriculture platform for disease diagnosis, logistics, and market access, where I led a three-person software team while contributing as a full-stack developer.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const OSTIM_TECHNOLOGIES = [
|
||||
".NET 10",
|
||||
"Angular",
|
||||
"Astro",
|
||||
"OMD UI Kit",
|
||||
"Tailwind CSS",
|
||||
];
|
||||
|
||||
export const WEB_APPS: ProjectItem[] = [
|
||||
{
|
||||
id: "ostim-web-portal",
|
||||
title: {
|
||||
tr: "OSTİM Web Portalı",
|
||||
en: "OSTİM Web Portal",
|
||||
},
|
||||
badge: {
|
||||
tr: "Kurumsal Portal",
|
||||
en: "Corporate Portal",
|
||||
},
|
||||
image: "/projects/ostim.webp",
|
||||
href: "https://ostim.org.tr",
|
||||
caseStudySlug: "ostim-web-portali",
|
||||
technologies: OSTIM_TECHNOLOGIES,
|
||||
architecture: {
|
||||
tr: "OSTİM Organize Sanayi Bölgesi ve yedi kümeye ait kurumsal içerikleri, firma ve ürün aramasını, çevrim içi işlemleri ve iletişim akışlarını tek portalda birleştiren çok bölümlü yapı.",
|
||||
en: "A multi-section architecture combining corporate content for OSTİM Organized Industrial Zone and its seven clusters with company and product search, online services, and communication flows in one portal.",
|
||||
},
|
||||
description: {
|
||||
tr: "OSTİM Organize Sanayi Bölgesi ve yedi kümesi için geliştirdiğimiz kapsamlı kurumsal web portalı.",
|
||||
en: "A comprehensive corporate web portal we developed for OSTİM Organized Industrial Zone and its seven clusters.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ostim-employment",
|
||||
title: {
|
||||
tr: "OSTİM İstihdam",
|
||||
en: "OSTİM Employment",
|
||||
},
|
||||
badge: {
|
||||
tr: "İstihdam Portalı",
|
||||
en: "Employment Portal",
|
||||
},
|
||||
image: "/projects/ostim-istihdam.webp",
|
||||
href: "https://ostimistihdam.com",
|
||||
caseStudySlug: "ostim-istihdam",
|
||||
technologies: OSTIM_TECHNOLOGIES,
|
||||
architecture: {
|
||||
tr: "İŞKUR senkronizasyonu üzerine kurulu; aday, işveren, iş ve staj ilanı akışlarını yapay zekâ destekli eşleştirme katmanıyla buluşturan rol tabanlı portal mimarisi.",
|
||||
en: "A role-based portal architecture built around İŞKUR synchronization, connecting candidate, employer, job, and internship workflows through an AI-assisted matching layer.",
|
||||
},
|
||||
description: {
|
||||
tr: "İŞKUR ile senkron çalışan, adayları iş ve staj fırsatlarıyla buluşturan istihdam portalı.",
|
||||
en: "An employment portal synchronized with İŞKUR that connects candidates with job and internship opportunities.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ostim-foreign-trade",
|
||||
title: {
|
||||
tr: "OSTİM Dış Ticaret",
|
||||
en: "OSTİM Foreign Trade",
|
||||
},
|
||||
badge: {
|
||||
tr: "Dış Ticaret Portalı",
|
||||
en: "Foreign Trade Portal",
|
||||
},
|
||||
image: "/projects/ostim-dis-ticaret.webp",
|
||||
href: "https://ostimdisticaret.net",
|
||||
technologies: OSTIM_TECHNOLOGIES,
|
||||
architecture: {
|
||||
tr: "Dış ticaret firmaları, ilanlar ve yabancı dil bilen öğrenciler için ayrı kayıt ve başvuru akışlarını ortak bir eşleştirme ve ilan havuzunda birleştiren çok taraflı portal mimarisi.",
|
||||
en: "A multi-sided portal architecture that brings registration and application flows for foreign-trade companies, listings, and multilingual students into a shared matching and opportunity pool.",
|
||||
},
|
||||
description: {
|
||||
tr: "Dış ticaret yapan firmaları yabancı dil bilen öğrencilerle buluşturmaya odaklanan portal.",
|
||||
en: "A portal focused on matching foreign-trade companies with students who speak foreign languages.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "arc-foreign-trade",
|
||||
title: "ARC Foreign Trade",
|
||||
@@ -35,7 +132,12 @@ export const WEB_APPS: ProjectItem[] = [
|
||||
tr: "Freelance",
|
||||
en: "Freelance",
|
||||
},
|
||||
image: "/projects/arc.png",
|
||||
image: "/projects/arc.webp",
|
||||
technologies: ["Wix"],
|
||||
architecture: {
|
||||
tr: "Wix üzerinde yönetilebilir içerik ve kurumsal tanıtım sayfalarından oluşan, ihracat odaklı web sitesi yapısı.",
|
||||
en: "An export-focused website architecture built on Wix with manageable content and corporate presentation pages.",
|
||||
},
|
||||
href: "https://arcforeigntrade.com",
|
||||
description: {
|
||||
tr: "Ankara merkezli ihracat odaklı bir üretici firma için kurumsal web sitesi yenileme projesi.",
|
||||
@@ -44,16 +146,24 @@ export const WEB_APPS: ProjectItem[] = [
|
||||
},
|
||||
{
|
||||
id: "ataturk-chronology",
|
||||
title: "Atatürk Kronolojisi",
|
||||
title: {
|
||||
tr: "Atatürk Kronolojisi",
|
||||
en: "Atatürk Chronology",
|
||||
},
|
||||
badge: {
|
||||
tr: "Açık Kaynak",
|
||||
en: "Open Source",
|
||||
},
|
||||
image: "/projects/ataturk.png",
|
||||
image: "/projects/ataturk.webp",
|
||||
technologies: ["React"],
|
||||
architecture: {
|
||||
tr: "React ile geliştirilen, kronoloji verisini etkileşimli bir zaman çizelgesi arayüzünde sunan istemci taraflı uygulama.",
|
||||
en: "A client-side React application presenting chronology data through an interactive timeline interface.",
|
||||
},
|
||||
href: "https://ataturk-kronolojisi.org",
|
||||
description: {
|
||||
tr: "Atatürk’ün hayatındaki önemli olayları, konuşmaları ve reformları etkileşimli bir zaman çizelgesiyle sunan web deneyimi.",
|
||||
en: "A web experience presenting important events, speeches, and reforms in Atatürk's life with an interactive timeline.",
|
||||
tr: "Açık kaynak katkıcısı olarak yer aldığım; Atatürk’ün hayatındaki önemli olayları, konuşmaları ve reformları etkileşimli bir zaman çizelgesiyle sunan web deneyimi.",
|
||||
en: "An interactive timeline of key events, speeches, and reforms in Atatürk's life, to which I contributed as an open-source contributor.",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -64,6 +174,11 @@ export const WEB_APPS: ProjectItem[] = [
|
||||
en: "Open Source",
|
||||
},
|
||||
image: "/projects/mockup.png",
|
||||
technologies: ["Next.js"],
|
||||
architecture: {
|
||||
tr: "Next.js tabanlı, görsel işleme akışını tamamen tarayıcıda çalıştıran istemci öncelikli araç mimarisi.",
|
||||
en: "A client-first Next.js tool architecture that runs its image-processing workflow entirely in the browser.",
|
||||
},
|
||||
href: "https://mockup-factory-mu.vercel.app/",
|
||||
description: {
|
||||
tr: "Görsellerin cihaz mockup’larına saniyeler içinde dönüştürüldüğü, tamamen tarayıcı üzerinde çalışan açık kaynak araç.",
|
||||
@@ -72,30 +187,50 @@ export const WEB_APPS: ProjectItem[] = [
|
||||
},
|
||||
{
|
||||
id: "ohhike",
|
||||
title: "Ohhike Coach",
|
||||
title: "OhHike",
|
||||
badge: {
|
||||
tr: "Açık Kaynak",
|
||||
en: "Open Source",
|
||||
tr: "Self-hosted",
|
||||
en: "Self-hosted",
|
||||
},
|
||||
image: "/projects/ohhike.png",
|
||||
technologies: [
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Vite",
|
||||
"Express.js",
|
||||
"Better Auth",
|
||||
"SQLite",
|
||||
"Drizzle ORM",
|
||||
],
|
||||
architecture: {
|
||||
tr: "pnpm/Turborepo monorepo içinde React/Vite arayüzü ve modüler monolit Express API; SQLite, Drizzle ORM, gerçek servis testleri ve Docker tabanlı self-hosted dağıtım.",
|
||||
en: "A React/Vite frontend and modular-monolith Express API in a pnpm/Turborepo monorepo, with SQLite, Drizzle ORM, real-service tests, and Docker-based self-hosted deployment.",
|
||||
},
|
||||
href: "https://www.ohhike.com",
|
||||
caseStudySlug: "ohhike",
|
||||
description: {
|
||||
tr: "Spor takımları için açık kaynaklı, yapay zekâ destekli antrenörlük zekâ platformu. OhHike CoachOS; sporcu check-in'lerini, antrenman notlarını, akıllı saat verilerini ve antrenman geçmişini aksiyona geçirilebilir bir takım hafızasına dönüştürür.",
|
||||
en: "An open-source, AI-powered coaching intelligence platform for sports teams. OhHike CoachOS turns athlete check-ins, training notes, smartwatch data, and session history into actionable team memory.",
|
||||
tr: "Masa başında çalışan geliştiriciler için aktivite, haftalık sağlık planı, beslenme ve kalori takibini bir araya getiren self-hosted uygulama.",
|
||||
en: "A self-hosted activity, weekly health planning, nutrition, and calorie tracking application for desk-bound developers.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "neta",
|
||||
title: "Take Neta",
|
||||
badge: {
|
||||
tr: "Açık Kaynak",
|
||||
en: "Open Source",
|
||||
tr: "Self-hosted",
|
||||
en: "Self-hosted",
|
||||
},
|
||||
image: "/projects/neta.png",
|
||||
technologies: ["Next.js", "TypeScript", "Express.js", "AI Integrations"],
|
||||
architecture: {
|
||||
tr: "Landing page, uygulama ve API paketlerini birlikte yöneten Next.js ve Express.js tabanlı monorepo mimarisi.",
|
||||
en: "A Next.js and Express.js monorepo architecture managing landing page, application, and API packages together.",
|
||||
},
|
||||
href: "https://www.takeneta.com",
|
||||
caseStudySlug: "take-neta",
|
||||
description: {
|
||||
tr: "Dijital ikinci beyniniz. Bilinçli üretkenlik ve yaşam takibi için hepsi bir arada kişisel işletim sistemi. Yerel öncelikli, yapay zekâ entegrasyonlu ve açık kaynaklı.",
|
||||
en: "Your digital second brain. An all-in-one personal operating system for mindful productivity and life-tracking. Local-first, AI-integrated, and open-source.",
|
||||
tr: "Freelancer'lar için görev, proje, müşteri, finans, yapay zekâ ve müşteri portalı akışlarını birleştiren self-hosted işletim sistemi.",
|
||||
en: "A self-hosted operating system for freelancers that unifies tasks, projects, clients, finance, AI, and client portal workflows.",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -109,6 +244,11 @@ export const EXTENSIONS: ProjectItem[] = [
|
||||
en: "Cross-Browser",
|
||||
},
|
||||
image: "/projects/quick-fill.png",
|
||||
technologies: ["JavaScript"],
|
||||
architecture: {
|
||||
tr: "Tarayıcı eklentisi API'leriyle çalışan, kısayol tanımlarını form alanlarına bağlayan çapraz tarayıcı JavaScript yapısı.",
|
||||
en: "A cross-browser JavaScript extension architecture that connects shortcut definitions to form fields through browser extension APIs.",
|
||||
},
|
||||
href: "https://github.com/poyrazavsever/shortcut-injector",
|
||||
description: {
|
||||
tr: "Özel klavye kısayollarını kullanarak önceden tanımlanmış kişisel verileri ve bağlantıları web formlarına hızlıca enjekte etmek için geliştirilmiş bir tarayıcı eklentisi.",
|
||||
@@ -123,6 +263,11 @@ export const EXTENSIONS: ProjectItem[] = [
|
||||
en: "Cross-Browser",
|
||||
},
|
||||
image: "/projects/sound_sync.png",
|
||||
technologies: ["JavaScript"],
|
||||
architecture: {
|
||||
tr: "Sekmelerin medya durumlarını izleyip oynatma komutlarını ileten olay tabanlı, çapraz tarayıcı eklenti mimarisi.",
|
||||
en: "An event-driven, cross-browser extension architecture that observes media state across tabs and relays playback commands.",
|
||||
},
|
||||
href: "https://github.com/poyrazavsever/tab-audio-relay",
|
||||
description: {
|
||||
tr: "Sekmeler arasındaki ses çalma işlemlerini senkronize eden bir tarayıcı eklentisi. Eğitim videonuz durduğunda müziğinizi otomatik olarak oynatır, eğitime devam ettiğinizde ise müziği duraklatır.",
|
||||
@@ -134,9 +279,14 @@ export const EXTENSIONS: ProjectItem[] = [
|
||||
export const FIGMA_TEMPLATES: ProjectItem[] = [
|
||||
{
|
||||
id: "hsd-website",
|
||||
title: "HSD Community Web Site",
|
||||
title: "HSD Community Website",
|
||||
badge: "Figma",
|
||||
image: "/projects/hsd.png",
|
||||
technologies: ["Figma"],
|
||||
architecture: {
|
||||
tr: "Bileşenler, kontrol paneli, açılış sayfası ve profil ekranlarını doğrudan Figma içinde düzenleyen bileşen tabanlı tasarım dosyası.",
|
||||
en: "A component-based design file organized directly in Figma across components, dashboard, landing page, and profile screens.",
|
||||
},
|
||||
href: "https://www.figma.com/community/file/1613511833232376739",
|
||||
description: {
|
||||
tr: "HSD Community için Web Site tasarımı. Bileşenler, kontrol paneli, açılış sayfası, profil sayfaları.",
|
||||
@@ -148,6 +298,11 @@ export const FIGMA_TEMPLATES: ProjectItem[] = [
|
||||
title: "Restaurant Menu UI Design",
|
||||
badge: "Figma",
|
||||
image: "/projects/menu.png",
|
||||
technologies: ["Figma"],
|
||||
architecture: {
|
||||
tr: "Tekrar kullanılabilir arayüz parçaları ve menü varyasyonlarından oluşan, doğrudan Figma üzerinde hazırlanan tasarım şablonu.",
|
||||
en: "A design template created directly in Figma with reusable interface elements and menu variants.",
|
||||
},
|
||||
href: "https://www.figma.com/community/file/1613577450975840169/restaurant-menu-ui-design",
|
||||
description: {
|
||||
tr: "Topluluk için Restaurant Menü Arayüz Tasarımı şablonu.",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export const REFERENCES: Reference[] = [
|
||||
en: "Client - 2025",
|
||||
},
|
||||
rating: 5,
|
||||
avatar: "/avatars/ali.png",
|
||||
avatar: "/avatars/ali.webp",
|
||||
quote: {
|
||||
tr: "Web sitesi tam olarak istediğim gibi oldu. Poyraz benim gözden kaçırdığım detayları da düşündü, her kararı şeffaf şekilde anlattı ve beklentimin üstünde bir iş teslim etti.",
|
||||
en: "The website turned out exactly as I wanted. Poyraz thought of the details I missed, explained every decision transparently, and delivered a job beyond my expectations.",
|
||||
|
||||
+25
-2
@@ -12,13 +12,36 @@ export const SPONSORS: Sponsor[] = [
|
||||
name: "Hostinger",
|
||||
job: "Web Hosting",
|
||||
logo: "/sponsors/hostinger.png",
|
||||
websiteUrl: "https://hostinger.com",
|
||||
websiteUrl: "https://hostinger.com/poyraz",
|
||||
},
|
||||
{
|
||||
id: "testsprite",
|
||||
name: "TestSprite",
|
||||
job: "Yazılım Test Otomasyonu",
|
||||
logo: "/sponsors/testsprite.png",
|
||||
websiteUrl: "https://testsprite.com",
|
||||
websiteUrl: "https://www.testsprite.com/?via=poyraz",
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
job: "AI Video & Model Platformu",
|
||||
logo: "/sponsors/minimax.png",
|
||||
websiteUrl:
|
||||
"https://platform.minimax.io/subscribe/coding-plan?code=7aH9b0Ya7c&source=link",
|
||||
},
|
||||
{
|
||||
id: "higgsfield",
|
||||
name: "Higgsfield",
|
||||
job: "AI Creative Suite",
|
||||
logo: "/sponsors/higgsfield.png",
|
||||
websiteUrl:
|
||||
"https://higgsfield.ai/s/higgsfield-mcp-3-0-yt-poyrazavsever-lLvqMw",
|
||||
},
|
||||
{
|
||||
id: "hosting-dunyam",
|
||||
name: "Hosting Dünyam",
|
||||
job: "Hosting Sağlayıcısı",
|
||||
logo: "/sponsors/hosting-dunyam.png",
|
||||
websiteUrl: "https://hostingdunyam.com",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Localized } from "@/lib/locale";
|
||||
|
||||
export type TechnologyStackItem = {
|
||||
id: string;
|
||||
label: string | Localized;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export type TechnologyStackGroup = {
|
||||
id: "frontend" | "backend" | "languages" | "designTools";
|
||||
items: readonly TechnologyStackItem[];
|
||||
};
|
||||
|
||||
export const TECHNOLOGY_STACK: readonly TechnologyStackGroup[] = [
|
||||
{
|
||||
id: "frontend",
|
||||
items: [
|
||||
{ id: "react", label: "React.js", icon: "simple-icons:react" },
|
||||
{ id: "nextjs", label: "Next.js", icon: "simple-icons:nextdotjs" },
|
||||
{ id: "angular", label: "Angular", icon: "simple-icons:angular" },
|
||||
{
|
||||
id: "react-native",
|
||||
label: "React Native",
|
||||
icon: "mdi:react",
|
||||
},
|
||||
{ id: "electron", label: "Electron.js", icon: "simple-icons:electron" },
|
||||
{
|
||||
id: "tailwindcss",
|
||||
label: "Tailwind CSS",
|
||||
icon: "simple-icons:tailwindcss",
|
||||
},
|
||||
{
|
||||
id: "bootstrap",
|
||||
label: "Bootstrap",
|
||||
icon: "simple-icons:bootstrap",
|
||||
},
|
||||
{ id: "redux", label: "Redux", icon: "simple-icons:redux" },
|
||||
{ id: "zustand", label: "Zustand", icon: "devicon:zustand" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "backend",
|
||||
items: [
|
||||
{ id: "nodejs", label: "Node.js", icon: "simple-icons:nodedotjs" },
|
||||
{ id: "express", label: "Express.js", icon: "simple-icons:express" },
|
||||
{ id: "nestjs", label: "Nest.js", icon: "simple-icons:nestjs" },
|
||||
{ id: "dotnet", label: ".NET", icon: "simple-icons:dotnet" },
|
||||
{
|
||||
id: "rest-api",
|
||||
label: { tr: "REST API'ler", en: "REST APIs" },
|
||||
icon: "mdi:api",
|
||||
},
|
||||
{
|
||||
id: "jwt-auth",
|
||||
label: "JWT Auth",
|
||||
icon: "simple-icons:jsonwebtokens",
|
||||
},
|
||||
{ id: "prisma", label: "Prisma", icon: "simple-icons:prisma" },
|
||||
{ id: "mongoose", label: "Mongoose", icon: "simple-icons:mongoose" },
|
||||
{
|
||||
id: "socket-io",
|
||||
label: "Socket.io",
|
||||
icon: "simple-icons:socketdotio",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
items: [
|
||||
{
|
||||
id: "javascript",
|
||||
label: "JavaScript",
|
||||
icon: "simple-icons:javascript",
|
||||
},
|
||||
{
|
||||
id: "typescript",
|
||||
label: "TypeScript",
|
||||
icon: "simple-icons:typescript",
|
||||
},
|
||||
{ id: "csharp", label: "C#", icon: "mdi:language-csharp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "designTools",
|
||||
items: [
|
||||
{ id: "figma", label: "Figma", icon: "simple-icons:figma" },
|
||||
{
|
||||
id: "wireframing",
|
||||
label: "Wireframing",
|
||||
icon: "mdi:vector-square",
|
||||
},
|
||||
{
|
||||
id: "design-systems",
|
||||
label: { tr: "Tasarım Sistemleri", en: "Design Systems" },
|
||||
icon: "mdi:palette-swatch-outline",
|
||||
},
|
||||
{ id: "git", label: "Git", icon: "simple-icons:git" },
|
||||
{ id: "github", label: "GitHub", icon: "simple-icons:github" },
|
||||
{ id: "firebase", label: "Firebase", icon: "simple-icons:firebase" },
|
||||
{ id: "supabase", label: "Supabase", icon: "simple-icons:supabase" },
|
||||
{
|
||||
id: "vercel-ai-sdk",
|
||||
label: "Vercel AI SDK",
|
||||
icon: "simple-icons:vercel",
|
||||
},
|
||||
{ id: "postman", label: "Postman", icon: "simple-icons:postman" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -13,6 +13,27 @@ export type VolunteerCommunityItem = {
|
||||
};
|
||||
|
||||
export const VOLUNTEER_COMMUNITY_ITEMS: VolunteerCommunityItem[] = [
|
||||
{
|
||||
id: "google-developer-student-club-tech-team-lead",
|
||||
title: "Google Developer Student Club — Tech Team Lead",
|
||||
timeline: {
|
||||
tr: "2026 - Günümüz",
|
||||
en: "2026 - Present",
|
||||
},
|
||||
focus: {
|
||||
tr: "Teknoloji ekibine liderlik ederek workshop ve hackathon organizasyonlarını planlamak, katılımcılara mentorluk yapmak ve topluluğun Instagram hesabı için teknik videolar üretmek.",
|
||||
en: "Leading the tech team by planning workshops and hackathons, mentoring participants, and producing technical videos for the community's Instagram account.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shipin-tech-core-team",
|
||||
title: "Shipin — Tech Core Team Member",
|
||||
timeline: "2026 - 2026",
|
||||
focus: {
|
||||
tr: "Topluluğun organizasyon ve etkinlik süreçlerine destek olmak, teknik sunumlar gerçekleştirmek ve katılımcılara mentorluk yapmak.",
|
||||
en: "Supporting community operations and event organization, delivering technical presentations, and mentoring participants.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "youtube",
|
||||
title: "YouTube",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type XVideo = {
|
||||
src: string;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
export const X_JAVASCRIPT_ANATOMY_VIDEOS: readonly XVideo[] = [
|
||||
{
|
||||
src: "/video/bolum11render.mp4",
|
||||
episode: 11,
|
||||
},
|
||||
{
|
||||
src: "/video/bolum12Render.mp4",
|
||||
episode: 12,
|
||||
},
|
||||
];
|
||||
|
||||
export const X_JAVASCRIPT_ANATOMY_URL = "https://x.com/poyrazavsever";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { REFERENCES } from "@/data/references";
|
||||
import { VOLUNTEER_COMMUNITY_ITEMS } from "@/data/volunteer-community";
|
||||
import { YOUTUBE_VIDEO_LINKS } from "@/data/youtube-videos";
|
||||
import { NAV_LINKS, SOCIAL_LINKS } from "@/lib/links";
|
||||
import { NAV_DROPDOWN_GROUPS, NAV_LINKS, SOCIAL_LINKS } from "@/lib/links";
|
||||
import { getLocalizedValue } from "@/lib/locale";
|
||||
|
||||
export type CommandPaletteItem = {
|
||||
@@ -28,10 +28,19 @@ export type CommandPaletteGroup = {
|
||||
items: CommandPaletteItem[];
|
||||
};
|
||||
|
||||
export type AnimationSourceSearchItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
platform: string;
|
||||
tools: string[];
|
||||
};
|
||||
|
||||
export function getCommandPaletteGroups(
|
||||
locale: string,
|
||||
tLinks: (key: string) => string,
|
||||
tNav: { (key: string): string; has: (key: string) => boolean }
|
||||
tNav: { (key: string): string; has: (key: string) => boolean },
|
||||
animationSources: AnimationSourceSearchItem[] = [],
|
||||
): CommandPaletteGroup[] {
|
||||
const navigationItems: CommandPaletteItem[] = NAV_LINKS.map((item) => {
|
||||
const label = tNav.has(item.id) ? tNav(item.id) : item.label;
|
||||
@@ -62,6 +71,39 @@ export function getCommandPaletteGroups(
|
||||
],
|
||||
}));
|
||||
|
||||
const dropdownGroups: CommandPaletteGroup[] = NAV_DROPDOWN_GROUPS.map(
|
||||
(group) => ({
|
||||
id: `navigation-${group.id}`,
|
||||
heading: tNav.has(group.id) ? tNav(group.id) : group.label,
|
||||
items: group.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: tNav.has(item.id) ? tNav(item.id) : item.label,
|
||||
href: item.href,
|
||||
icon: item.icon,
|
||||
external: item.external,
|
||||
keywords: [group.label, item.label, ...item.keywords],
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
const animationSourceItems: CommandPaletteItem[] = animationSources.map(
|
||||
(source) => ({
|
||||
id: `animation-source-${source.slug}`,
|
||||
label: source.title,
|
||||
href: `/animation-sources/${source.slug}`,
|
||||
icon: "mdi:motion-play-outline",
|
||||
keywords: [
|
||||
source.excerpt,
|
||||
source.platform,
|
||||
...source.tools,
|
||||
"animation",
|
||||
"prompt",
|
||||
locale === "tr" ? "animasyon" : "motion",
|
||||
locale === "tr" ? "kaynak" : "source",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const blogItems: CommandPaletteItem[] = [
|
||||
{
|
||||
id: "blog-index",
|
||||
@@ -70,6 +112,18 @@ export function getCommandPaletteGroups(
|
||||
icon: "mdi:file-document-outline",
|
||||
keywords: ["blog", locale === "tr" ? "yazı" : "post", "article"],
|
||||
},
|
||||
{
|
||||
id: "agenda-index",
|
||||
label: locale === "tr" ? "Haftalık Gündem" : "Weekly Agenda",
|
||||
href: "/agenda",
|
||||
icon: "mdi:newspaper-variant-outline",
|
||||
keywords: [
|
||||
"newsletter",
|
||||
locale === "tr" ? "gündem" : "agenda",
|
||||
locale === "tr" ? "haftalık" : "weekly",
|
||||
locale === "tr" ? "haber" : "news",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "blog-content-page",
|
||||
label: locale === "tr" ? "Video ve Notlar" : "Videos and Notes",
|
||||
@@ -175,11 +229,12 @@ export function getCommandPaletteGroups(
|
||||
|
||||
const projectItems: CommandPaletteItem[] = [
|
||||
...MOBILE_APPS.map((item) => {
|
||||
const title = getLocalizedValue(item.title, locale);
|
||||
const description = getLocalizedValue(item.description, locale);
|
||||
const badgeStr = item.badge ? getLocalizedValue(item.badge, locale) : "";
|
||||
return {
|
||||
id: `mobile-project-${item.id}`,
|
||||
label: item.title,
|
||||
label: title,
|
||||
href: "/projects",
|
||||
icon: "mdi:cellphone",
|
||||
keywords: [
|
||||
@@ -191,11 +246,12 @@ export function getCommandPaletteGroups(
|
||||
};
|
||||
}),
|
||||
...WEB_APPS.map((item) => {
|
||||
const title = getLocalizedValue(item.title, locale);
|
||||
const description = getLocalizedValue(item.description, locale);
|
||||
const badgeStr = item.badge ? getLocalizedValue(item.badge, locale) : "";
|
||||
return {
|
||||
id: `web-project-${item.id}`,
|
||||
label: item.title,
|
||||
label: title,
|
||||
href: item.href ?? "/projects",
|
||||
icon: "mdi:web",
|
||||
external: Boolean(item.href),
|
||||
@@ -208,11 +264,12 @@ export function getCommandPaletteGroups(
|
||||
};
|
||||
}),
|
||||
...EXTENSIONS.map((item) => {
|
||||
const title = getLocalizedValue(item.title, locale);
|
||||
const description = getLocalizedValue(item.description, locale);
|
||||
const badgeStr = item.badge ? getLocalizedValue(item.badge, locale) : "";
|
||||
return {
|
||||
id: `extension-project-${item.id}`,
|
||||
label: item.title,
|
||||
label: title,
|
||||
href: item.href ?? "/projects",
|
||||
icon: "mdi:puzzle-outline",
|
||||
external: Boolean(item.href),
|
||||
@@ -226,11 +283,12 @@ export function getCommandPaletteGroups(
|
||||
};
|
||||
}),
|
||||
...FIGMA_TEMPLATES.map((item) => {
|
||||
const title = getLocalizedValue(item.title, locale);
|
||||
const description = getLocalizedValue(item.description, locale);
|
||||
const badgeStr = item.badge ? getLocalizedValue(item.badge, locale) : "";
|
||||
return {
|
||||
id: `figma-project-${item.id}`,
|
||||
label: item.title,
|
||||
label: title,
|
||||
href: item.href ?? "/projects",
|
||||
icon: "mdi:figma",
|
||||
external: Boolean(item.href),
|
||||
@@ -281,6 +339,18 @@ export function getCommandPaletteGroups(
|
||||
heading: locale === "tr" ? "İçerikler" : "Contents",
|
||||
items: contentItems,
|
||||
},
|
||||
...dropdownGroups,
|
||||
...(animationSourceItems.length > 0
|
||||
? [
|
||||
{
|
||||
id: "animation-sources-data",
|
||||
heading: tNav.has("animationResources")
|
||||
? tNav("animationResources")
|
||||
: "Animation Sources",
|
||||
items: animationSourceItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "social",
|
||||
heading: "Social",
|
||||
|
||||
+43
-3
@@ -2,9 +2,49 @@ import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const PDF_DIR = path.join(process.cwd(), "public", "pdf");
|
||||
const PDF_THUMBNAIL_DIR = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"pdf-thumbnails",
|
||||
);
|
||||
const PDF_LIMIT = 3;
|
||||
|
||||
export async function getPdfNotes() {
|
||||
const files = await readdir(PDF_DIR);
|
||||
export type PdfNote = {
|
||||
fileName: string;
|
||||
title: string;
|
||||
href: string;
|
||||
thumbnailSrc: string | null;
|
||||
};
|
||||
|
||||
function formatPdfTitle(fileName: string) {
|
||||
return fileName
|
||||
.replace(/\.pdf$/i, "")
|
||||
.split(/[-_]+/)
|
||||
.map((part) => part.charAt(0).toLocaleUpperCase("tr-TR") + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export async function getPdfNotes(): Promise<PdfNote[]> {
|
||||
const [files, thumbnailFiles] = await Promise.all([
|
||||
readdir(PDF_DIR),
|
||||
readdir(PDF_THUMBNAIL_DIR).catch(() => [] as string[]),
|
||||
]);
|
||||
const thumbnails = new Set(thumbnailFiles);
|
||||
const pdfFiles = files.filter((file) => file.toLowerCase().endsWith(".pdf"));
|
||||
return pdfFiles.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return pdfFiles
|
||||
.sort((a, b) => a.localeCompare(b, "tr"))
|
||||
.slice(0, PDF_LIMIT)
|
||||
.map((fileName) => {
|
||||
const thumbnailName = `${fileName.replace(/\.pdf$/i, "")}.jpg`;
|
||||
|
||||
return {
|
||||
fileName,
|
||||
title: formatPdfTitle(fileName),
|
||||
href: `/pdf/${fileName}`,
|
||||
thumbnailSrc: thumbnails.has(thumbnailName)
|
||||
? `/pdf-thumbnails/${thumbnailName}`
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const TOTAL_DURATION = 4;
|
||||
export const ACTIVE_START = 0.25;
|
||||
export const ACTIVE_END = 3.75;
|
||||
export const DEFAULT_TIME = 2;
|
||||
export const SMOOTHING = 0.12;
|
||||
|
||||
export function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
export function mapPointerYToTime(pointerY: number, viewportHeight: number) {
|
||||
if (viewportHeight <= 0) return DEFAULT_TIME;
|
||||
|
||||
const progress = clamp(1 - pointerY / viewportHeight, 0, 1);
|
||||
return ACTIVE_START + progress * (ACTIVE_END - ACTIVE_START);
|
||||
}
|
||||
+82
-9
@@ -56,12 +56,6 @@ export const SOCIAL_LINKS = [
|
||||
href: "https://behance.net/poyrazavsever",
|
||||
icon: "mdi:behance",
|
||||
},
|
||||
{
|
||||
id: "spotify",
|
||||
label: "Spotify",
|
||||
href: "https://open.spotify.com/user/3136fdjkc5p4cbzmuxhvqdd4b2hu",
|
||||
icon: "mdi:spotify",
|
||||
},
|
||||
{
|
||||
id: "buy-me-a-coffee",
|
||||
label: "Bana kahve ısmarla",
|
||||
@@ -70,6 +64,33 @@ export const SOCIAL_LINKS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const NAV_DROPDOWN_GROUPS = [
|
||||
{
|
||||
id: "others",
|
||||
label: "Diğerleri",
|
||||
icon: "mdi:dots-horizontal",
|
||||
insertAfter: "gallery",
|
||||
items: [
|
||||
{
|
||||
id: "agenda",
|
||||
label: "Haftalık Gündem",
|
||||
href: "/agenda",
|
||||
icon: "mdi:newspaper-variant-outline",
|
||||
external: false,
|
||||
keywords: ["gündem", "agenda", "newsletter", "haftalık", "weekly", "haber"],
|
||||
},
|
||||
{
|
||||
id: "animationResources",
|
||||
label: "Animasyon Kaynakları",
|
||||
href: "/animation-sources",
|
||||
icon: "mdi:motion-play-outline",
|
||||
external: false,
|
||||
keywords: ["animasyon", "animation", "kaynak", "resource", "motion"],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const TOP_ICON_LINKS = [
|
||||
{
|
||||
id: "ui-kit",
|
||||
@@ -126,15 +147,67 @@ export const LINK_DIRECTORY_CATEGORIES: ReadonlyArray<{
|
||||
{ id: "resources", label: "Kaynaklar" },
|
||||
];
|
||||
|
||||
export const LINK_DIRECTORY: LinkDirectoryItem[] = [
|
||||
const STATIC_PAGE_LINKS = [
|
||||
{
|
||||
id: "home",
|
||||
label: "Ana Sayfa",
|
||||
href: "/",
|
||||
icon: "mdi:home-outline",
|
||||
keywords: ["ana sayfa", "home", "portfolio"],
|
||||
},
|
||||
...NAV_LINKS.map((item) => ({
|
||||
...item,
|
||||
icon: "mdi:compass-outline",
|
||||
keywords: [item.label, item.href, "sayfa", "navigasyon", "internal"],
|
||||
})),
|
||||
...NAV_DROPDOWN_GROUPS.flatMap((group) =>
|
||||
group.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
icon: item.icon,
|
||||
keywords: [...item.keywords],
|
||||
})),
|
||||
),
|
||||
{
|
||||
id: "references",
|
||||
label: "Referanslar",
|
||||
href: "/about/references",
|
||||
icon: "mdi:comment-quote-outline",
|
||||
keywords: ["referans", "references", "testimonial", "yorum"],
|
||||
},
|
||||
{
|
||||
id: "volunteerCommunity",
|
||||
label: "Gönüllülük ve Topluluk",
|
||||
href: "/about/volunteer-community",
|
||||
icon: "mdi:account-group-outline",
|
||||
keywords: ["gönüllülük", "topluluk", "volunteer", "community"],
|
||||
},
|
||||
{
|
||||
id: "mediaKit",
|
||||
label: "Medya Kiti",
|
||||
href: "/media-kit",
|
||||
icon: "mdi:chart-box-outline",
|
||||
keywords: ["medya kiti", "media kit", "sponsor", "iş birliği"],
|
||||
},
|
||||
{
|
||||
id: "links",
|
||||
label: "Bağlantılar",
|
||||
href: "/links",
|
||||
icon: "mdi:link-variant",
|
||||
keywords: ["bağlantılar", "links", "link in bio"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const LINK_DIRECTORY: LinkDirectoryItem[] = [
|
||||
...STATIC_PAGE_LINKS.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
icon: "mdi:compass-outline",
|
||||
icon: item.icon,
|
||||
external: false,
|
||||
category: "navigation" as const,
|
||||
keywords: [item.label, item.href, "sayfa", "navigasyon", "internal"],
|
||||
keywords: [...item.keywords],
|
||||
})),
|
||||
...SOCIAL_LINKS.map((item) => ({
|
||||
id: item.id,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type MarkdownHeading = {
|
||||
id: string;
|
||||
text: string;
|
||||
level: 2 | 3;
|
||||
};
|
||||
|
||||
export function cleanMarkdownHeading(text: string) {
|
||||
return text
|
||||
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
|
||||
.replace(/[*_~`]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function slugifyMarkdownHeading(text: string) {
|
||||
return cleanMarkdownHeading(text)
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/ı/g, "i")
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function parseMarkdownHeadings(markdown: string): MarkdownHeading[] {
|
||||
const headings: MarkdownHeading[] = [];
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const match = /^(#{2,3})\s+(.+)$/.exec(line.trim());
|
||||
if (!match) continue;
|
||||
|
||||
const text = cleanMarkdownHeading(match[2]);
|
||||
const id = slugifyMarkdownHeading(text);
|
||||
if (!id || !text) continue;
|
||||
|
||||
headings.push({
|
||||
id,
|
||||
text,
|
||||
level: match[1].length as 2 | 3,
|
||||
});
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
@@ -24,6 +24,12 @@ export type GithubActivity = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GithubContributionDay = {
|
||||
date: string;
|
||||
count: number;
|
||||
level: 0 | 1 | 2 | 3 | 4;
|
||||
};
|
||||
|
||||
type GithubRepoApi = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -136,3 +142,54 @@ export async function getGithubActivity(): Promise<GithubActivity[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGithubContributions(
|
||||
html: string,
|
||||
): GithubContributionDay[] {
|
||||
const contributions: GithubContributionDay[] = [];
|
||||
const cellPattern =
|
||||
/<td\b([^>]*\bdata-date="[^"]+"[^>]*)><\/td>\s*<tool-tip\b[^>]*>([\s\S]*?)<\/tool-tip>/g;
|
||||
|
||||
for (const match of html.matchAll(cellPattern)) {
|
||||
const attributes = match[1];
|
||||
const tooltip = match[2].replace(/<[^>]+>/g, "").trim();
|
||||
const date = attributes.match(/data-date="([^"]+)"/)?.[1];
|
||||
const levelValue = attributes.match(/data-level="([0-4])"/)?.[1];
|
||||
const countValue = tooltip.match(/^([\d,]+)\s+contributions?\b/i)?.[1];
|
||||
|
||||
if (!date || levelValue === undefined) continue;
|
||||
|
||||
contributions.push({
|
||||
date,
|
||||
count: countValue ? Number(countValue.replaceAll(",", "")) : 0,
|
||||
level: Number(levelValue) as GithubContributionDay["level"],
|
||||
});
|
||||
}
|
||||
|
||||
return contributions.sort((first, second) =>
|
||||
first.date.localeCompare(second.date),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getGithubContributions(): Promise<
|
||||
GithubContributionDay[]
|
||||
> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://github.com/users/${USERNAME}/contributions`,
|
||||
{
|
||||
next: { revalidate: 3600 },
|
||||
headers: {
|
||||
Accept: "text/html",
|
||||
"User-Agent": "portfolio-new",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
return parseGithubContributions(await response.text());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
function resolveSiteUrl() {
|
||||
const url = new URL(
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://www.poyrazavsever.com",
|
||||
);
|
||||
|
||||
// The public site redirects the apex domain to `www`. Canonicals must point
|
||||
// directly to the final URL instead of a redirecting host.
|
||||
if (url.hostname === "poyrazavsever.com") {
|
||||
url.hostname = "www.poyrazavsever.com";
|
||||
}
|
||||
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export const SITE_URL = resolveSiteUrl();
|
||||
|
||||
export type SiteLocale = "tr" | "en";
|
||||
|
||||
type LocalePaths = Partial<Record<SiteLocale, string>>;
|
||||
|
||||
type StaticSeoPage =
|
||||
| "home"
|
||||
| "about"
|
||||
| "references"
|
||||
| "volunteerCommunity"
|
||||
| "blog"
|
||||
| "projects"
|
||||
| "content"
|
||||
| "gallery"
|
||||
| "links"
|
||||
| "contact";
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path || path === "/") return "/";
|
||||
return `/${path.replace(/^\/+|\/+$/g, "")}`;
|
||||
}
|
||||
|
||||
export function getLocalizedPath(locale: SiteLocale, path: string) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
return normalizedPath === "/"
|
||||
? `/${locale}`
|
||||
: `/${locale}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function getAbsoluteUrl(path: string) {
|
||||
return new URL(path, `${SITE_URL}/`).toString();
|
||||
}
|
||||
|
||||
export function getLocalizedUrl(locale: SiteLocale, path: string) {
|
||||
return getAbsoluteUrl(getLocalizedPath(locale, path));
|
||||
}
|
||||
|
||||
export function createAlternates(
|
||||
locale: SiteLocale,
|
||||
paths: LocalePaths,
|
||||
): Metadata["alternates"] {
|
||||
const currentPath = paths[locale];
|
||||
if (!currentPath) return undefined;
|
||||
|
||||
const languages: Record<string, string> = {};
|
||||
|
||||
if (paths.tr) {
|
||||
languages["tr-TR"] = getLocalizedUrl("tr", paths.tr);
|
||||
}
|
||||
if (paths.en) {
|
||||
languages["en-US"] = getLocalizedUrl("en", paths.en);
|
||||
}
|
||||
|
||||
languages["x-default"] = paths.tr
|
||||
? getLocalizedUrl("tr", paths.tr)
|
||||
: getLocalizedUrl(locale, currentPath);
|
||||
|
||||
return {
|
||||
canonical: getLocalizedUrl(locale, currentPath),
|
||||
languages,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPageMetadata({
|
||||
locale,
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
absoluteTitle = false,
|
||||
}: {
|
||||
locale: SiteLocale;
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
absoluteTitle?: boolean;
|
||||
}): Metadata {
|
||||
const url = getLocalizedUrl(locale, path);
|
||||
|
||||
return {
|
||||
title: absoluteTitle ? { absolute: title } : title,
|
||||
description,
|
||||
alternates: createAlternates(locale, { tr: path, en: path }),
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Poyraz Avsever",
|
||||
locale: locale === "tr" ? "tr_TR" : "en_US",
|
||||
alternateLocale: locale === "tr" ? ["en_US"] : ["tr_TR"],
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
images: [
|
||||
{
|
||||
url: "/og.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Poyraz Avsever",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
creator: "@poyrazavsever",
|
||||
images: ["/og.png"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStaticPageMetadata({
|
||||
locale,
|
||||
page,
|
||||
path,
|
||||
absoluteTitle = false,
|
||||
}: {
|
||||
locale: string;
|
||||
page: StaticSeoPage;
|
||||
path: string;
|
||||
absoluteTitle?: boolean;
|
||||
}) {
|
||||
const siteLocale = locale === "en" ? "en" : "tr";
|
||||
const t = await getTranslations({ locale: siteLocale, namespace: "Seo" });
|
||||
|
||||
return createPageMetadata({
|
||||
locale: siteLocale,
|
||||
title: t(`${page}.title`),
|
||||
description: t(`${page}.description`),
|
||||
path,
|
||||
absoluteTitle,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type YouTubeChannelStats = {
|
||||
subscribers: number | null;
|
||||
views: number;
|
||||
videos: number;
|
||||
};
|
||||
|
||||
type YouTubeChannelsResponse = {
|
||||
items?: Array<{
|
||||
statistics?: {
|
||||
hiddenSubscriberCount?: boolean;
|
||||
subscriberCount?: string;
|
||||
viewCount?: string;
|
||||
videoCount?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
function parseCount(value: string | undefined) {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export async function getYouTubeChannelStats(): Promise<YouTubeChannelStats | null> {
|
||||
const apiKey = process.env.YOUTUBE_API_KEY?.trim();
|
||||
if (!apiKey) return null;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
part: "statistics",
|
||||
key: apiKey,
|
||||
});
|
||||
const channelId = process.env.YOUTUBE_CHANNEL_ID?.trim();
|
||||
|
||||
if (channelId) {
|
||||
params.set("id", channelId);
|
||||
} else {
|
||||
params.set("forHandle", "@poyrazavsever");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/channels?${params.toString()}`,
|
||||
{ next: { revalidate: 3600 } },
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = (await response.json()) as YouTubeChannelsResponse;
|
||||
const statistics = data.items?.[0]?.statistics;
|
||||
const views = parseCount(statistics?.viewCount);
|
||||
const videos = parseCount(statistics?.videoCount);
|
||||
|
||||
if (!statistics || views === null || videos === null) return null;
|
||||
|
||||
return {
|
||||
subscribers: statistics.hiddenSubscriberCount
|
||||
? null
|
||||
: parseCount(statistics.subscriberCount),
|
||||
views,
|
||||
videos,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+172
-4
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"Nav": {
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"blog": "Blog",
|
||||
"agenda": "Weekly Agenda",
|
||||
"content": "Content",
|
||||
"projects": "Projects",
|
||||
"gallery": "Gallery",
|
||||
@@ -9,6 +11,13 @@
|
||||
"search": "Search",
|
||||
"social": "Social",
|
||||
"socialLinks": "Social Links",
|
||||
"others": "Others",
|
||||
"animationResources": "Animation Resources",
|
||||
"links": "Links",
|
||||
"mediaKit": "Media Kit",
|
||||
"references": "References",
|
||||
"volunteerCommunity": "Volunteering & Community",
|
||||
"backToMenu": "Back to menu",
|
||||
"menu": "Menu",
|
||||
"mobileMenu": "Mobile Menu",
|
||||
"resume": "Resume"
|
||||
@@ -24,6 +33,13 @@
|
||||
"allProjects": "All projects",
|
||||
"reviewsAndReferences": "Reviews & References",
|
||||
"allReferences": "All references",
|
||||
"technologiesTitle": "Technologies I use",
|
||||
"technologyCategories": {
|
||||
"frontend": "Front End",
|
||||
"backend": "Back End",
|
||||
"languages": "Programming Languages",
|
||||
"designTools": "Design & Tools"
|
||||
},
|
||||
"sections": {
|
||||
"recentPosts": "Recent Posts",
|
||||
"references": "References",
|
||||
@@ -31,6 +47,58 @@
|
||||
"sponsors": "Sponsors"
|
||||
}
|
||||
},
|
||||
"LayoutPromos": {
|
||||
"leftRailLabel": "Featured content",
|
||||
"rightRailLabel": "Partnerships and contact",
|
||||
"slideNavigationLabel": "Promotional slides",
|
||||
"slideCta": "Go to slide {slide}",
|
||||
"slideStatus": "Slide {current} of {total}",
|
||||
"weeklyEyebrow": "This week",
|
||||
"weeklyTitle": "Have you read this week's agenda?",
|
||||
"weeklyFallback": "Weekly developments from software, technology, design, and artificial intelligence.",
|
||||
"weeklyCta": "Read the agenda",
|
||||
"projectsTitle": "Curious how I build my projects?",
|
||||
"projectsDescription": "Explore the decisions, architecture, and outcomes behind selected products through detailed case studies.",
|
||||
"projectsCta": "Explore projects",
|
||||
"latestPostEyebrow": "New post",
|
||||
"latestPostTitle": "What's new on the blog?",
|
||||
"latestPostFallback": "Read my latest notes on software and product development.",
|
||||
"latestPostCta": "Read the post",
|
||||
"anatomyTitle": "Have you watched JavaScript Anatomy?",
|
||||
"anatomyDescription": "Explore my series explaining JavaScript concepts through concise 1-4 minute landscape videos.",
|
||||
"anatomyCta": "Watch the series",
|
||||
"youtubeEyebrow": "YouTube",
|
||||
"youtubeTitle": "Join a community of 8K+ people.",
|
||||
"youtubeDescription": "Watch practical videos about software, artificial intelligence, and product development.",
|
||||
"youtubeCta": "Visit the channel",
|
||||
"designSystemEyebrow": "Poyraz UI",
|
||||
"designSystemTitle": "Explore the design system behind this site.",
|
||||
"designSystemDescription": "See the components, design decisions, and live examples I use across the portfolio.",
|
||||
"designSystemCta": "Open the docs",
|
||||
"communityEyebrow": "Community",
|
||||
"communityTitle": "What do I do beyond writing code?",
|
||||
"communityDescription": "Explore my workshops, hackathons, mentoring, and volunteer community work.",
|
||||
"communityCta": "Community work",
|
||||
"referencesEyebrow": "References",
|
||||
"referencesTitle": "What do the people I work with say?",
|
||||
"referencesDescription": "Read feedback from teammates and people I have built products with.",
|
||||
"referencesCta": "Read references",
|
||||
"sponsorsEyebrow": "Sponsors",
|
||||
"sponsorsTitle": "These brands have sponsored my work so far.",
|
||||
"sponsorsDescription": "Would you like to introduce your brand to my software and technology-focused audience?",
|
||||
"sponsorsCta": "Become a sponsor",
|
||||
"contactTitle": "Have an idea? Let's build it together.",
|
||||
"contactDescription": "Reach out directly for a project, partnership, or content idea.",
|
||||
"contactCta": "Get in touch",
|
||||
"linkedinEyebrow": "LinkedIn",
|
||||
"linkedinTitle": "Follow my professional journey.",
|
||||
"linkedinDescription": "I share my projects, technical experiences, and notes from the industry on LinkedIn.",
|
||||
"linkedinCta": "Visit LinkedIn",
|
||||
"instagramEyebrow": "Instagram",
|
||||
"instagramTitle": "Technology, in a shorter format.",
|
||||
"instagramDescription": "Follow for concise technical videos, moments from events, and a look behind my creative process.",
|
||||
"instagramCta": "Follow on Instagram"
|
||||
},
|
||||
"About": {
|
||||
"title": "About me",
|
||||
"contactCta": "Contact me",
|
||||
@@ -61,9 +129,38 @@
|
||||
},
|
||||
"viewNpmPackages": "View my NPM packages",
|
||||
"visitGithub": "Visit my GitHub profile",
|
||||
"technologies": "Technologies",
|
||||
"architecture": "Architecture",
|
||||
"viewCaseStudy": "View case study",
|
||||
"contributionCalendar": "poyrazavsever GitHub contribution calendar",
|
||||
"contributionUnavailable": "GitHub contribution data is currently unavailable.",
|
||||
"noContributions": "No contributions",
|
||||
"contributionSingular": "contribution",
|
||||
"contributionPlural": "contributions",
|
||||
"emptyNpm": "npm API response is currently empty.",
|
||||
"emptyGithub": "GitHub API response is currently empty."
|
||||
},
|
||||
"ProjectCaseStudy": {
|
||||
"back": "Back to all projects",
|
||||
"liveDemo": "Live site",
|
||||
"sourceCode": "Source code",
|
||||
"overview": "Project overview",
|
||||
"roleAndTeam": "Role and team structure",
|
||||
"role": "My role",
|
||||
"team": "Team",
|
||||
"context": "Context",
|
||||
"problemConstraints": "Problem and constraints",
|
||||
"constraints": "Key constraints",
|
||||
"architectureDecisions": "Architecture decisions",
|
||||
"designProcess": "Design process",
|
||||
"challenge": "The challenge",
|
||||
"solution": "Solution",
|
||||
"results": "Results and scope indicators",
|
||||
"screenshots": "Screenshots",
|
||||
"openScreenshot": "Open full size",
|
||||
"technologies": "Technology stack",
|
||||
"repository": "Source code status"
|
||||
},
|
||||
"Blog": {
|
||||
"recentPosts": "Recent Posts",
|
||||
"searchPlaceholder": "Search posts...",
|
||||
@@ -82,13 +179,36 @@
|
||||
"diagramLoading": "Diagram rendering...",
|
||||
"diagramError": "Could not render Mermaid diagram."
|
||||
},
|
||||
"Agenda": {
|
||||
"title": "Weekly Agenda",
|
||||
"description": "Weekly developments and commentary from software, technology, design, and artificial intelligence.",
|
||||
"searchPlaceholder": "Search the weekly agenda...",
|
||||
"empty": "No agenda posts yet.",
|
||||
"noSearch": "No agenda posts found for \"{search}\".",
|
||||
"backToAgenda": "<- Back to Weekly Agenda"
|
||||
},
|
||||
"AnimationSources": {
|
||||
"title": "Animation Sources",
|
||||
"description": "Production notes, prompts, and reusable code from animations I create for web and mobile products.",
|
||||
"empty": "No animation sources have been added yet.",
|
||||
"back": "Back to animation sources",
|
||||
"toc": "Table of Contents",
|
||||
"closeToc": "Close table of contents",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"Links": {
|
||||
"title": "My links",
|
||||
"desc": "Here you can find all my social accounts, portfolio pages, and quick access links in one place. Open and share directly.",
|
||||
"socialLinks": "Social media links",
|
||||
"quickLinks": "Quick access",
|
||||
"quickLinksDesc": "Frequently used projects, resources, and resume",
|
||||
"allLinks": "All Links",
|
||||
"allLinksDesc": "Pages, resources, and social profiles",
|
||||
"selectCategory": "Select category",
|
||||
"allCategories": "All categories",
|
||||
"searchPlaceholder": "Search links... github, medium, /blog",
|
||||
"searchAriaLabel": "Search links",
|
||||
"empty": "No links found matching the selected filter.",
|
||||
"categories": {
|
||||
"navigation": "Pages",
|
||||
@@ -102,10 +222,53 @@
|
||||
"prev": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"Seo": {
|
||||
"home": {
|
||||
"title": "Poyraz Avsever | Portfolio",
|
||||
"description": "The portfolio of Poyraz Avsever, featuring full-stack products, AI systems, modern web projects, and practical software content."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Explore Poyraz Avsever's software journey, professional experience, education, certificates, and community work."
|
||||
},
|
||||
"references": {
|
||||
"title": "References",
|
||||
"description": "Read professional testimonials and references from people who have worked with Poyraz Avsever."
|
||||
},
|
||||
"volunteerCommunity": {
|
||||
"title": "Volunteering and Community",
|
||||
"description": "Explore Poyraz Avsever's volunteer work, technology community contributions, and event experience."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"description": "Technical articles about frontend development, JavaScript, web architecture, user experience, and software engineering."
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
"description": "Explore Poyraz Avsever's web applications, mobile products, open-source tools, browser extensions, and design work."
|
||||
},
|
||||
"content": {
|
||||
"title": "Content",
|
||||
"description": "Poyraz Avsever's YouTube videos, LinkedIn PDF notes, and JavaScript Anatomy video series."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Gallery",
|
||||
"description": "Selected images from Poyraz Avsever's events, presentations, projects, and community work."
|
||||
},
|
||||
"links": {
|
||||
"title": "Links",
|
||||
"description": "Access Poyraz Avsever's social profiles, projects, content, and essential links from one page."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact",
|
||||
"description": "Contact Poyraz Avsever by email or social media for projects, collaborations, and professional opportunities."
|
||||
}
|
||||
},
|
||||
"Metadata": {
|
||||
"title": "Poyraz Avsever | Portfolio",
|
||||
"titleTemplate": "%s | Poyraz Avsever",
|
||||
"description": "Poyraz Avsever's personal portfolio website sharing projects, software content, and technical works."
|
||||
"description": "Poyraz Avsever's personal portfolio website sharing projects, software content, and technical works.",
|
||||
"socialImageAlt": "Poyraz Avsever portfolio social preview"
|
||||
},
|
||||
"SearchCommand": {
|
||||
"placeholder": "Search pages and links...",
|
||||
@@ -113,14 +276,19 @@
|
||||
"footer": "Press {shortcut} to open quickly"
|
||||
},
|
||||
"Content": {
|
||||
"youtubeTitle": "Latest YouTube Videos",
|
||||
"youtubeTitlePrefix": "Latest",
|
||||
"youtubeTitle": "Videos",
|
||||
"youtubeChannel": "My YouTube channel",
|
||||
"youtubeEmbedTitle": "YouTube video player",
|
||||
"pdfTitle": "LinkedIn PDF Notes",
|
||||
"pdfTitle": "PDF Notes",
|
||||
"linkedinProfile": "My LinkedIn profile",
|
||||
"pdfPreviewError": "Preview could not be loaded",
|
||||
"pdfPreviewTitle": "{name} preview",
|
||||
"openPdf": "Open the {name} PDF note",
|
||||
"pdfModalDefaultTitle": "PDF Note",
|
||||
"xTitle": "JavaScript Anatomy",
|
||||
"xSeries": "My JavaScript Anatomy series on X",
|
||||
"xVideoTitle": "JavaScript Anatomy, episode {episode}",
|
||||
"videoUnsupported": "Your browser does not support video playback.",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"pdfNotFound": "No PDF files found inside `/public/pdf`."
|
||||
|
||||
+172
-4
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"Nav": {
|
||||
"home": "Ana Sayfa",
|
||||
"about": "Hakkımda",
|
||||
"blog": "Blog",
|
||||
"agenda": "Haftalık Gündem",
|
||||
"content": "İçerikler",
|
||||
"projects": "Projeler",
|
||||
"gallery": "Galeri",
|
||||
@@ -9,6 +11,13 @@
|
||||
"search": "Ara",
|
||||
"social": "Sosyal",
|
||||
"socialLinks": "Sosyal Bağlantılar",
|
||||
"others": "Diğerleri",
|
||||
"animationResources": "Animasyon Kaynakları",
|
||||
"links": "Bağlantılar",
|
||||
"mediaKit": "Medya Kiti",
|
||||
"references": "Referanslar",
|
||||
"volunteerCommunity": "Gönüllük ve Topluluk",
|
||||
"backToMenu": "Menüye dön",
|
||||
"menu": "Menü",
|
||||
"mobileMenu": "Mobil Menü",
|
||||
"resume": "Özgeçmiş"
|
||||
@@ -24,6 +33,13 @@
|
||||
"allProjects": "Tüm projeler",
|
||||
"reviewsAndReferences": "Yorumlar & Referanslar",
|
||||
"allReferences": "Tüm referanslar",
|
||||
"technologiesTitle": "Ben bu teknolojileri kullanıyorum",
|
||||
"technologyCategories": {
|
||||
"frontend": "Ön Yüz",
|
||||
"backend": "Arka Yüz",
|
||||
"languages": "Programlama Dilleri",
|
||||
"designTools": "Tasarım ve Araçlar"
|
||||
},
|
||||
"sections": {
|
||||
"recentPosts": "Son Yazılar",
|
||||
"references": "Referanslar",
|
||||
@@ -31,6 +47,58 @@
|
||||
"sponsors": "Sponsorlar"
|
||||
}
|
||||
},
|
||||
"LayoutPromos": {
|
||||
"leftRailLabel": "Öne çıkan içerikler",
|
||||
"rightRailLabel": "İş birlikleri ve iletişim",
|
||||
"slideNavigationLabel": "Tanıtım slaytları",
|
||||
"slideCta": "{slide}. slayda git",
|
||||
"slideStatus": "{current} / {total}. slayt",
|
||||
"weeklyEyebrow": "Bu hafta",
|
||||
"weeklyTitle": "Bu haftanın gündemini okudun mu?",
|
||||
"weeklyFallback": "Yazılım, teknoloji, tasarım ve yapay zekâ dünyasından haftalık gelişmeler.",
|
||||
"weeklyCta": "Gündemi oku",
|
||||
"projectsTitle": "Nasıl geliştirdiğimi merak ediyor musun?",
|
||||
"projectsDescription": "Seçili projelerde aldığım kararları, mimariyi ve ortaya çıkan sonuçları vaka çalışmalarıyla incele.",
|
||||
"projectsCta": "Projeleri incele",
|
||||
"latestPostEyebrow": "Yeni yazı",
|
||||
"latestPostTitle": "Blogda yeni ne var?",
|
||||
"latestPostFallback": "Yazılım ve ürün geliştirme üzerine son notlarıma göz at.",
|
||||
"latestPostCta": "Yazıyı oku",
|
||||
"anatomyTitle": "JavaScript Anatomisi'ni izledin mi?",
|
||||
"anatomyDescription": "JavaScript kavramlarını 1-4 dakikalık kısa ve yatay videolarla anlattığım seriyi keşfet.",
|
||||
"anatomyCta": "Seriyi izle",
|
||||
"youtubeEyebrow": "YouTube",
|
||||
"youtubeTitle": "8 B+ kişilik topluluğa katıl.",
|
||||
"youtubeDescription": "Yazılım, yapay zekâ ve ürün geliştirme üzerine uygulamalı videoları izle.",
|
||||
"youtubeCta": "Kanala git",
|
||||
"designSystemEyebrow": "Poyraz UI",
|
||||
"designSystemTitle": "Bu sitenin tasarım sistemini keşfet.",
|
||||
"designSystemDescription": "Kullandığım bileşenleri, tasarım kararlarını ve canlı örnekleri incele.",
|
||||
"designSystemCta": "Dokümantasyonu aç",
|
||||
"communityEyebrow": "Topluluk",
|
||||
"communityTitle": "Kodun dışında neler yapıyorum?",
|
||||
"communityDescription": "Workshop, hackathon, mentörlük ve gönüllülük çalışmalarımı incele.",
|
||||
"communityCta": "Topluluk çalışmaları",
|
||||
"referencesEyebrow": "Referanslar",
|
||||
"referencesTitle": "Birlikte çalıştığım insanlar ne diyor?",
|
||||
"referencesDescription": "Ekip arkadaşlarımdan ve birlikte ürettiğim insanlardan gelen yorumları oku.",
|
||||
"referencesCta": "Referansları oku",
|
||||
"sponsorsEyebrow": "Sponsorlar",
|
||||
"sponsorsTitle": "Bak, bunlar bana şimdiye kadar sponsor oldu.",
|
||||
"sponsorsDescription": "Sen de markanı yazılım ve teknoloji odaklı kitlemle buluşturmak ister misin?",
|
||||
"sponsorsCta": "Sen de sponsor ol",
|
||||
"contactTitle": "Bir fikrin mi var? Birlikte üretelim.",
|
||||
"contactDescription": "Proje, iş birliği veya içerik fikrin için doğrudan iletişime geç.",
|
||||
"contactCta": "İletişime geç",
|
||||
"linkedinEyebrow": "LinkedIn",
|
||||
"linkedinTitle": "Profesyonel yolculuğumu takip et.",
|
||||
"linkedinDescription": "Projelerimi, teknik deneyimlerimi ve sektörden notlarımı LinkedIn'de paylaşıyorum.",
|
||||
"linkedinCta": "LinkedIn'e git",
|
||||
"instagramEyebrow": "Instagram",
|
||||
"instagramTitle": "Teknolojinin daha kısa hâli burada.",
|
||||
"instagramDescription": "Kısa teknik videolar, etkinliklerden anlar ve üretim sürecimin perde arkası için takip et.",
|
||||
"instagramCta": "Instagram'da takip et"
|
||||
},
|
||||
"About": {
|
||||
"title": "Hakkımda",
|
||||
"contactCta": "İletişime geç",
|
||||
@@ -61,9 +129,38 @@
|
||||
},
|
||||
"viewNpmPackages": "NPM paketlerimi gör",
|
||||
"visitGithub": "GitHub hesabına git",
|
||||
"technologies": "Teknolojiler",
|
||||
"architecture": "Mimari",
|
||||
"viewCaseStudy": "Vaka çalışmasını incele",
|
||||
"contributionCalendar": "poyrazavsever GitHub katkı takvimi",
|
||||
"contributionUnavailable": "GitHub katkı verisi şu anda alınamıyor.",
|
||||
"noContributions": "Katkı yok",
|
||||
"contributionSingular": "katkı",
|
||||
"contributionPlural": "katkı",
|
||||
"emptyNpm": "npm API yanıtı şu anda boş.",
|
||||
"emptyGithub": "GitHub API yanıtı şu anda boş."
|
||||
},
|
||||
"ProjectCaseStudy": {
|
||||
"back": "Tüm projelere dön",
|
||||
"liveDemo": "Canlı site",
|
||||
"sourceCode": "Kaynak kod",
|
||||
"overview": "Proje özeti",
|
||||
"roleAndTeam": "Rol ve ekip yapısı",
|
||||
"role": "Rolüm",
|
||||
"team": "Ekip",
|
||||
"context": "Bağlam",
|
||||
"problemConstraints": "Problem ve kısıtlar",
|
||||
"constraints": "Temel kısıtlar",
|
||||
"architectureDecisions": "Mimari kararlar",
|
||||
"designProcess": "Tasarım süreci",
|
||||
"challenge": "Karşılaşılan zorluk",
|
||||
"solution": "Çözüm",
|
||||
"results": "Sonuçlar ve kapsam göstergeleri",
|
||||
"screenshots": "Ekran görüntüleri",
|
||||
"openScreenshot": "Tam boyutta aç",
|
||||
"technologies": "Kullanılan teknolojiler",
|
||||
"repository": "Kaynak kod durumu"
|
||||
},
|
||||
"Blog": {
|
||||
"recentPosts": "Son Yazılar",
|
||||
"searchPlaceholder": "Yazılarda ara...",
|
||||
@@ -82,13 +179,36 @@
|
||||
"diagramLoading": "Diyagram hazırlanıyor...",
|
||||
"diagramError": "Mermaid diyagramı oluşturulamadı."
|
||||
},
|
||||
"Agenda": {
|
||||
"title": "Haftalık Gündem",
|
||||
"description": "Yazılım, teknoloji, tasarım ve yapay zeka dünyasından haftalık gelişmeler ve yorumlar.",
|
||||
"searchPlaceholder": "Haftalık gündemde ara...",
|
||||
"empty": "Henüz gündem yazısı bulunmuyor.",
|
||||
"noSearch": "\"{search}\" aramasına uygun gündem yazısı bulunamadı.",
|
||||
"backToAgenda": "<- Haftalık Gündem'e dön"
|
||||
},
|
||||
"AnimationSources": {
|
||||
"title": "Animasyon Kaynakları",
|
||||
"description": "Web ve mobil ürünler için hazırladığım animasyonların üretim süreçleri, promptları ve uygulanabilir kodları.",
|
||||
"empty": "Henüz animasyon kaynağı eklenmedi.",
|
||||
"back": "Animasyon kaynaklarına dön",
|
||||
"toc": "İçindekiler",
|
||||
"closeToc": "İçindekileri kapat",
|
||||
"copy": "Kopyala",
|
||||
"copied": "Kopyalandı"
|
||||
},
|
||||
"Links": {
|
||||
"title": "Bağlantılarım",
|
||||
"desc": "Burada sosyal hesaplarım, portfolyo sayfalarım ve hızlı erişim linklerimin tamamı tek yerde duruyor. Direkt açıp paylaşabilirsin.",
|
||||
"socialLinks": "Sosyal medya bağlantıları",
|
||||
"quickLinks": "Hızlı erişim",
|
||||
"quickLinksDesc": "Sık kullanılan projeler, kaynaklar ve özgeçmiş",
|
||||
"allLinks": "Tüm Linkler",
|
||||
"allLinksDesc": "Sayfalar, kaynaklar ve sosyal profiller",
|
||||
"selectCategory": "Kategori seç",
|
||||
"allCategories": "Tüm kategoriler",
|
||||
"searchPlaceholder": "Link ara... github, medium, /blog",
|
||||
"searchAriaLabel": "Bağlantılarda ara",
|
||||
"empty": "Seçili filtreye uygun link bulunamadı.",
|
||||
"categories": {
|
||||
"navigation": "Sayfalar",
|
||||
@@ -102,10 +222,53 @@
|
||||
"prev": "Önceki",
|
||||
"next": "Sonraki"
|
||||
},
|
||||
"Seo": {
|
||||
"home": {
|
||||
"title": "Poyraz Avsever | Portfolyo",
|
||||
"description": "Full-stack ürünler, yapay zeka sistemleri, modern web projeleri ve uygulanabilir yazılım içerikleri üreten Poyraz Avsever'in portfolyosu."
|
||||
},
|
||||
"about": {
|
||||
"title": "Hakkımda",
|
||||
"description": "Poyraz Avsever'in yazılım yolculuğunu, deneyimini, eğitimini, sertifikalarını ve topluluk çalışmalarını keşfedin."
|
||||
},
|
||||
"references": {
|
||||
"title": "Referanslar",
|
||||
"description": "Birlikte çalıştığım kişilerin Poyraz Avsever hakkındaki profesyonel değerlendirmelerini ve referanslarını inceleyin."
|
||||
},
|
||||
"volunteerCommunity": {
|
||||
"title": "Gönüllülük ve Topluluk",
|
||||
"description": "Poyraz Avsever'in gönüllülük çalışmalarını, teknoloji topluluklarına katkılarını ve etkinlik deneyimlerini inceleyin."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Blog",
|
||||
"description": "Frontend, JavaScript, web mimarisi, kullanıcı deneyimi ve yazılım geliştirme üzerine teknik yazılar."
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projeler",
|
||||
"description": "Poyraz Avsever'in web uygulamaları, mobil ürünleri, açık kaynak araçları, tarayıcı eklentileri ve tasarım çalışmalarını keşfedin."
|
||||
},
|
||||
"content": {
|
||||
"title": "İçerikler",
|
||||
"description": "Poyraz Avsever'in YouTube videoları, LinkedIn PDF notları ve JavaScript Anatomisi video serisi."
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galeri",
|
||||
"description": "Poyraz Avsever'in etkinlik, sunum, proje ve topluluk çalışmalarından seçilmiş görseller."
|
||||
},
|
||||
"links": {
|
||||
"title": "Bağlantılar",
|
||||
"description": "Poyraz Avsever'in sosyal medya hesaplarına, projelerine, içeriklerine ve hızlı erişim bağlantılarına tek sayfadan ulaşın."
|
||||
},
|
||||
"contact": {
|
||||
"title": "İletişim",
|
||||
"description": "Proje, iş birliği ve profesyonel iletişim için Poyraz Avsever'e e-posta veya sosyal medya üzerinden ulaşın."
|
||||
}
|
||||
},
|
||||
"Metadata": {
|
||||
"title": "Poyraz Avsever | Portfolyo",
|
||||
"titleTemplate": "%s | Poyraz Avsever",
|
||||
"description": "Poyraz Avsever'in projelerini, yazılım içeriklerini ve teknik çalışmalarını paylaştığı kişisel portfolyo sitesi."
|
||||
"description": "Poyraz Avsever'in projelerini, yazılım içeriklerini ve teknik çalışmalarını paylaştığı kişisel portfolyo sitesi.",
|
||||
"socialImageAlt": "Poyraz Avsever portfolyo paylaşım görseli"
|
||||
},
|
||||
"SearchCommand": {
|
||||
"placeholder": "Sayfa ve bağlantılarda ara...",
|
||||
@@ -113,14 +276,19 @@
|
||||
"footer": "Hızlıca açmak için {shortcut} kullan"
|
||||
},
|
||||
"Content": {
|
||||
"youtubeTitle": "Son YouTube Videoları",
|
||||
"youtubeTitlePrefix": "Son",
|
||||
"youtubeTitle": "Videoları",
|
||||
"youtubeChannel": "YouTube kanalım",
|
||||
"youtubeEmbedTitle": "YouTube video oynatıcı",
|
||||
"pdfTitle": "LinkedIn PDF Notları",
|
||||
"pdfTitle": "PDF Notları",
|
||||
"linkedinProfile": "LinkedIn profilim",
|
||||
"pdfPreviewError": "Önizleme yüklenemedi",
|
||||
"pdfPreviewTitle": "{name} önizleme",
|
||||
"openPdf": "{name} PDF notunu aç",
|
||||
"pdfModalDefaultTitle": "PDF Notu",
|
||||
"xTitle": "JavaScript Anatomisi",
|
||||
"xSeries": "X'teki JavaScript Anatomisi serim",
|
||||
"xVideoTitle": "JavaScript Anatomisi, bölüm {episode}",
|
||||
"videoUnsupported": "Tarayıcınız video oynatmayı desteklemiyor.",
|
||||
"prev": "Önceki",
|
||||
"next": "Sonraki",
|
||||
"pdfNotFound": "/public/pdf içinde PDF bulunamadı."
|
||||
|
||||
@@ -17,6 +17,38 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "i.ytimg.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.pexels.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "miro.medium.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "www.c-sharpcorner.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "media.geeksforgeeks.org",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "edward-huang.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "encrypted-tbn0.gstatic.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.hashnode.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "i.sstatic.net",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"mermaid": "^11.13.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "4.13.0",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"poyraz-ui": "3.0.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
Generated
+1
-134
@@ -26,9 +26,6 @@ importers:
|
||||
next-intl:
|
||||
specifier: 4.13.0
|
||||
version: 4.13.0(next@16.1.6(@babel/core@7.29.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||
pdfjs-dist:
|
||||
specifier: ^5.5.207
|
||||
version: 5.7.284
|
||||
poyraz-ui:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(mermaid@11.16.0)(react-dom@19.2.3(react@19.2.3))(react-hook-form@7.81.0(react@19.2.3))(react@19.2.3)(tailwindcss@4.3.2)(zod@4.4.3)
|
||||
@@ -529,81 +526,6 @@ packages:
|
||||
'@mixmark-io/domino@2.2.0':
|
||||
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
@@ -2885,6 +2807,7 @@ packages:
|
||||
eslint@9.39.5:
|
||||
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -4243,10 +4166,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
pdfjs-dist@5.7.284:
|
||||
resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
@@ -5714,54 +5633,6 @@ snapshots:
|
||||
|
||||
'@mixmark-io/domino@2.2.0': {}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.100
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.100
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.100
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
@@ -9922,10 +9793,6 @@ snapshots:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.3
|
||||
|
||||
pdfjs-dist@5.7.284:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.100
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
performance-now@2.1.0: {}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 928 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user