feat(animation-sources): improve discovery and sharing
This commit is contained in:
@@ -40,23 +40,36 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `${SITE_URL}${getLocalizedPath(locale, source.slug)}`;
|
const url = `${SITE_URL}${getLocalizedPath(locale, source.slug)}`;
|
||||||
|
const socialImageUrl = new URL(source.coverImage, SITE_URL).toString();
|
||||||
|
const turkishUrl = `${SITE_URL}${getLocalizedPath("tr", source.slug)}`;
|
||||||
|
const englishUrl = `${SITE_URL}${getLocalizedPath("en", source.slug)}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: source.title,
|
title: source.title,
|
||||||
description: source.excerpt,
|
description: source.excerpt,
|
||||||
alternates: { canonical: url },
|
alternates: {
|
||||||
|
canonical: url,
|
||||||
|
languages: {
|
||||||
|
"tr-TR": turkishUrl,
|
||||||
|
"en-US": englishUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: source.title,
|
title: source.title,
|
||||||
description: source.excerpt,
|
description: source.excerpt,
|
||||||
url,
|
url,
|
||||||
|
siteName: "Poyraz Avsever",
|
||||||
type: "article",
|
type: "article",
|
||||||
|
locale: locale === "en" ? "en_US" : "tr_TR",
|
||||||
|
alternateLocale: locale === "en" ? ["tr_TR"] : ["en_US"],
|
||||||
publishedTime: source.date,
|
publishedTime: source.date,
|
||||||
authors: [source.author],
|
authors: [source.author],
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: source.coverImage,
|
url: socialImageUrl,
|
||||||
width: 720,
|
type: "image/gif",
|
||||||
height: 720,
|
width: 480,
|
||||||
|
height: 480,
|
||||||
alt: source.title,
|
alt: source.title,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -65,7 +78,13 @@ export async function generateMetadata({
|
|||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: source.title,
|
title: source.title,
|
||||||
description: source.excerpt,
|
description: source.excerpt,
|
||||||
images: [source.coverImage],
|
creator: "@poyrazavsever",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: socialImageUrl,
|
||||||
|
alt: source.title,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { AnimationSourcesContent } from "@/components/animation-sources-content";
|
import { AnimationSourcesContent } from "@/components/animation-sources-content";
|
||||||
import { listAnimationSources } from "@/data/animation-sources";
|
import { listAnimationSources } from "@/data/animation-sources";
|
||||||
|
|
||||||
|
const SITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com";
|
||||||
|
|
||||||
type AnimationSourcesPageProps = {
|
type AnimationSourcesPageProps = {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
};
|
};
|
||||||
@@ -11,13 +14,49 @@ export async function generateMetadata({
|
|||||||
params,
|
params,
|
||||||
}: AnimationSourcesPageProps): Promise<Metadata> {
|
}: AnimationSourcesPageProps): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: "AnimationSources" });
|
const [t, sources] = await Promise.all([
|
||||||
|
getTranslations({ locale, namespace: "AnimationSources" }),
|
||||||
|
listAnimationSources(locale),
|
||||||
|
]);
|
||||||
|
const localizedPath = locale === "en" ? "/en/animation-sources" : "/animation-sources";
|
||||||
|
const url = `${SITE_URL}${localizedPath}`;
|
||||||
|
const socialImagePath = sources[0]?.coverImage ?? "/logo/logo.png";
|
||||||
|
const socialImageUrl = new URL(socialImagePath, SITE_URL).toString();
|
||||||
|
const isGif = socialImagePath.toLowerCase().endsWith(".gif");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
description: t("description"),
|
description: t("description"),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: "/animation-sources",
|
canonical: url,
|
||||||
|
languages: {
|
||||||
|
"tr-TR": `${SITE_URL}/animation-sources`,
|
||||||
|
"en-US": `${SITE_URL}/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: isGif ? "image/gif" : "image/png",
|
||||||
|
width: isGif ? 480 : 1200,
|
||||||
|
height: isGif ? 480 : 1200,
|
||||||
|
alt: t("title"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: t("title"),
|
||||||
|
description: t("description"),
|
||||||
|
creator: "@poyrazavsever",
|
||||||
|
images: [{ url: socialImageUrl, alt: t("title") }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -8,6 +8,7 @@ import "../globals.css";
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { GoogleAnalytics } from "@/components/google-analytics";
|
import { GoogleAnalytics } from "@/components/google-analytics";
|
||||||
import { PoyrazBottomRightFollower } from "@/components/poyraz-bottom-right-follower";
|
import { PoyrazBottomRightFollower } from "@/components/poyraz-bottom-right-follower";
|
||||||
|
import { listAnimationSources } from "@/data/animation-sources";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -108,14 +109,26 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Provide messages for NextIntlClientProvider
|
// Provide messages for NextIntlClientProvider
|
||||||
const messages = await getMessages();
|
const [messages, animationSources] = await Promise.all([
|
||||||
|
getMessages(),
|
||||||
|
listAnimationSources(locale),
|
||||||
|
]);
|
||||||
|
const animationSourceSearchItems = animationSources.map((source) => ({
|
||||||
|
slug: source.slug,
|
||||||
|
title: source.title,
|
||||||
|
excerpt: source.excerpt,
|
||||||
|
platform: source.platform,
|
||||||
|
tools: source.tools,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<body className="min-h-dvh bg-background text-foreground antialiased">
|
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<GoogleAnalytics />
|
<GoogleAnalytics />
|
||||||
<AppShell>{children}</AppShell>
|
<AppShell animationSources={animationSourceSearchItems}>
|
||||||
|
{children}
|
||||||
|
</AppShell>
|
||||||
<PoyrazBottomRightFollower />
|
<PoyrazBottomRightFollower />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { Card, Typography } from "poyraz-ui/atoms";
|
||||||
import { Badge, Card, Typography } from "poyraz-ui/atoms";
|
import { NewsCard } from "poyraz-ui/molecules";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import type { AnimationSource } from "@/data/animation-sources";
|
import type { AnimationSource } from "@/data/animation-sources";
|
||||||
|
|
||||||
@@ -34,38 +34,21 @@ export function AnimationSourcesContent({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{sources.length > 0 ? (
|
{sources.length > 0 ? (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="space-y-3">
|
||||||
{sources.map((source) => (
|
{sources.map((source) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${source.lang}-${source.slug}`}
|
key={`${source.lang}-${source.slug}`}
|
||||||
href={`/animation-sources/${source.slug}`}
|
href={`/animation-sources/${source.slug}`}
|
||||||
data-animation-source-card
|
data-animation-source-card
|
||||||
className="group min-w-0"
|
className="block min-w-0"
|
||||||
>
|
>
|
||||||
<Card className="h-full overflow-hidden rounded-sm border-border p-0 transition-colors group-hover:border-red-600/60">
|
<NewsCard
|
||||||
<div className="relative aspect-square overflow-hidden bg-white">
|
image={source.coverImage}
|
||||||
<Image
|
category={source.platform}
|
||||||
src={source.coverImage}
|
title={source.title}
|
||||||
alt=""
|
date={source.date}
|
||||||
fill
|
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"
|
||||||
unoptimized={source.coverImage.toLowerCase().endsWith(".gif")}
|
|
||||||
sizes="(max-width: 640px) 50vw, 280px"
|
|
||||||
className="object-cover transition-transform duration-300 group-hover:scale-[1.02] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="space-y-2 border-t border-border p-3">
|
|
||||||
<Badge variant="outline" className="rounded-sm">
|
|
||||||
{source.platform}
|
|
||||||
</Badge>
|
|
||||||
<Typography
|
|
||||||
variant="large"
|
|
||||||
data-animation-source-title
|
|
||||||
className="line-clamp-2 min-h-10 text-sm leading-5 sm:text-base"
|
|
||||||
>
|
|
||||||
{source.title}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ANNOUNCEMENT_ITEMS, ENABLE_NEKO_FOLLOWER } from "@/data/site-settings";
|
|||||||
import { useLocale } from "next-intl";
|
import { useLocale } from "next-intl";
|
||||||
import { getLocalizedValue } from "@/lib/locale";
|
import { getLocalizedValue } from "@/lib/locale";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import type { AnimationSourceSearchItem } from "@/lib/command-palette-links";
|
||||||
|
|
||||||
const AtaturkWidgetModal = dynamic(
|
const AtaturkWidgetModal = dynamic(
|
||||||
() => import("@/components/ataturk-widget-modal").then((mod) => mod.AtaturkWidgetModal),
|
() => import("@/components/ataturk-widget-modal").then((mod) => mod.AtaturkWidgetModal),
|
||||||
@@ -17,6 +18,7 @@ const AtaturkWidgetModal = dynamic(
|
|||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
animationSources: AnimationSourceSearchItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark";
|
export type ThemeMode = "light" | "dark";
|
||||||
@@ -30,7 +32,7 @@ function getInitialTheme(): ThemeMode {
|
|||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children, animationSources }: AppShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const announcement = ANNOUNCEMENT_ITEMS[0];
|
const announcement = ANNOUNCEMENT_ITEMS[0];
|
||||||
@@ -57,7 +59,11 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
<AtaturkWidgetModal theme={theme} />
|
<AtaturkWidgetModal theme={theme} />
|
||||||
{ENABLE_NEKO_FOLLOWER ? <NekoFollower /> : null}
|
{ENABLE_NEKO_FOLLOWER ? <NekoFollower /> : null}
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col px-4 py-4 ">
|
<div className="mx-auto flex w-full max-w-4xl flex-col px-4 py-4 ">
|
||||||
<SiteNavbar theme={theme} onThemeChange={setTheme} />
|
<SiteNavbar
|
||||||
|
theme={theme}
|
||||||
|
onThemeChange={setTheme}
|
||||||
|
animationSources={animationSources}
|
||||||
|
/>
|
||||||
{announcement ? (
|
{announcement ? (
|
||||||
<AnnouncementBar
|
<AnnouncementBar
|
||||||
variant="branded"
|
variant="branded"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "poyraz-ui/molecules";
|
} from "poyraz-ui/molecules";
|
||||||
import {
|
import {
|
||||||
getCommandPaletteGroups,
|
getCommandPaletteGroups,
|
||||||
|
type AnimationSourceSearchItem,
|
||||||
type CommandPaletteItem as PaletteItem,
|
type CommandPaletteItem as PaletteItem,
|
||||||
} from "@/lib/command-palette-links";
|
} from "@/lib/command-palette-links";
|
||||||
import { useKeyboardShortcutLabel } from "@/lib/use-keyboard-shortcut-label";
|
import { useKeyboardShortcutLabel } from "@/lib/use-keyboard-shortcut-label";
|
||||||
@@ -24,9 +25,14 @@ import { useKeyboardShortcutLabel } from "@/lib/use-keyboard-shortcut-label";
|
|||||||
type SearchCommandProps = {
|
type SearchCommandProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
animationSources: AnimationSourceSearchItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SearchCommand({ open, onOpenChange }: SearchCommandProps) {
|
export function SearchCommand({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
animationSources,
|
||||||
|
}: SearchCommandProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const tLinks = useTranslations("Links");
|
const tLinks = useTranslations("Links");
|
||||||
@@ -36,8 +42,8 @@ export function SearchCommand({ open, onOpenChange }: SearchCommandProps) {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
return getCommandPaletteGroups(locale, tLinks, tNav);
|
return getCommandPaletteGroups(locale, tLinks, tNav, animationSources);
|
||||||
}, [locale, tLinks, tNav]);
|
}, [animationSources, locale, tLinks, tNav]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
TOP_ICON_LINKS,
|
TOP_ICON_LINKS,
|
||||||
} from "@/lib/links";
|
} from "@/lib/links";
|
||||||
import type { ThemeMode } from "@/components/app-shell";
|
import type { ThemeMode } from "@/components/app-shell";
|
||||||
|
import type { AnimationSourceSearchItem } from "@/lib/command-palette-links";
|
||||||
|
|
||||||
const SearchCommand = dynamic(
|
const SearchCommand = dynamic(
|
||||||
() => import("@/components/search-command").then((mod) => mod.SearchCommand),
|
() => import("@/components/search-command").then((mod) => mod.SearchCommand),
|
||||||
@@ -98,9 +99,15 @@ 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 pathname = usePathname();
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
@@ -545,7 +552,11 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SearchCommand open={searchOpen} onOpenChange={setSearchOpen} />
|
<SearchCommand
|
||||||
|
open={searchOpen}
|
||||||
|
onOpenChange={setSearchOpen}
|
||||||
|
animationSources={animationSources}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,10 +28,19 @@ export type CommandPaletteGroup = {
|
|||||||
items: CommandPaletteItem[];
|
items: CommandPaletteItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AnimationSourceSearchItem = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
platform: string;
|
||||||
|
tools: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export function getCommandPaletteGroups(
|
export function getCommandPaletteGroups(
|
||||||
locale: string,
|
locale: string,
|
||||||
tLinks: (key: string) => string,
|
tLinks: (key: string) => string,
|
||||||
tNav: { (key: string): string; has: (key: string) => boolean }
|
tNav: { (key: string): string; has: (key: string) => boolean },
|
||||||
|
animationSources: AnimationSourceSearchItem[] = [],
|
||||||
): CommandPaletteGroup[] {
|
): CommandPaletteGroup[] {
|
||||||
const navigationItems: CommandPaletteItem[] = NAV_LINKS.map((item) => {
|
const navigationItems: CommandPaletteItem[] = NAV_LINKS.map((item) => {
|
||||||
const label = tNav.has(item.id) ? tNav(item.id) : item.label;
|
const label = tNav.has(item.id) ? tNav(item.id) : item.label;
|
||||||
@@ -77,6 +86,24 @@ export function getCommandPaletteGroups(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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[] = [
|
const blogItems: CommandPaletteItem[] = [
|
||||||
{
|
{
|
||||||
id: "blog-index",
|
id: "blog-index",
|
||||||
@@ -297,6 +324,17 @@ export function getCommandPaletteGroups(
|
|||||||
items: contentItems,
|
items: contentItems,
|
||||||
},
|
},
|
||||||
...dropdownGroups,
|
...dropdownGroups,
|
||||||
|
...(animationSourceItems.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "animation-sources-data",
|
||||||
|
heading: tNav.has("animationResources")
|
||||||
|
? tNav("animationResources")
|
||||||
|
: "Animation Sources",
|
||||||
|
items: animationSourceItems,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "social",
|
id: "social",
|
||||||
heading: "Social",
|
heading: "Social",
|
||||||
|
|||||||
Reference in New Issue
Block a user