feat: refactor layout and links page, add AppShell component for improved structure
This commit is contained in:
+2
-34
@@ -1,12 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { SiteNavbar } from "@/components/site-navbar";
|
||||
import { NekoFollower } from "@/components/neko-follower";
|
||||
import { ANNOUNCEMENT_ITEMS } from "@/data/site-settings";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { AnnouncementBar } from "poyraz-ui/organisms";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://poyrazavsever.com"),
|
||||
@@ -87,37 +82,10 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const announcement = ANNOUNCEMENT_ITEMS[0];
|
||||
|
||||
return (
|
||||
<html lang="tr">
|
||||
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||
<NekoFollower />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col overflow-hidden px-4 py-4 sm:px-6">
|
||||
<SiteNavbar />
|
||||
{announcement ? (
|
||||
<AnnouncementBar
|
||||
variant="branded"
|
||||
dismissible={false}
|
||||
icon={<Icon icon="mdi:sparkles" width={16} height={16} />}
|
||||
action={
|
||||
announcement.actionLabel && announcement.actionHref ? (
|
||||
<Link
|
||||
href={announcement.actionHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-bold underline"
|
||||
>
|
||||
{announcement.actionLabel}
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{announcement.text}
|
||||
</AnnouncementBar>
|
||||
) : null}
|
||||
<main className="flex-1 overflow-hidden py-4">{children}</main>
|
||||
</div>
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+32
-1
@@ -1,4 +1,35 @@
|
||||
import { LinksContent } from "@/components/links-content";
|
||||
import type { Metadata } from "next";
|
||||
import { LinksContent } from "@/components/links-content";
|
||||
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function LinksPage() {
|
||||
return <LinksContent />;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnnouncementBar } from "poyraz-ui/organisms";
|
||||
import { SiteNavbar } from "@/components/site-navbar";
|
||||
import { NekoFollower } from "@/components/neko-follower";
|
||||
import { ANNOUNCEMENT_ITEMS } from "@/data/site-settings";
|
||||
|
||||
type AppShellProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const announcement = ANNOUNCEMENT_ITEMS[0];
|
||||
const isStandaloneLinksPage = pathname === "/links" || pathname.startsWith("/links/");
|
||||
|
||||
if (isStandaloneLinksPage) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NekoFollower />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col overflow-hidden px-4 py-4 sm:px-6">
|
||||
<SiteNavbar />
|
||||
{announcement ? (
|
||||
<AnnouncementBar
|
||||
variant="branded"
|
||||
dismissible={false}
|
||||
icon={<Icon icon="mdi:sparkles" width={16} height={16} />}
|
||||
action={
|
||||
announcement.actionLabel && announcement.actionHref ? (
|
||||
<Link
|
||||
href={announcement.actionHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-bold underline"
|
||||
>
|
||||
{announcement.actionLabel}
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{announcement.text}
|
||||
</AnnouncementBar>
|
||||
) : null}
|
||||
<main className="flex-1 overflow-hidden py-4">{children}</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+213
-103
@@ -1,33 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Typography,
|
||||
} from "poyraz-ui/atoms";
|
||||
import { Tabs, TabsList, TabsTrigger } from "poyraz-ui/molecules";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
|
||||
import {
|
||||
LINK_DIRECTORY,
|
||||
LINK_DIRECTORY_CATEGORIES,
|
||||
SOCIAL_LINKS,
|
||||
TOP_ICON_LINKS,
|
||||
type LinkDirectoryCategory,
|
||||
} from "@/lib/links";
|
||||
|
||||
type CategoryFilter = "all" | LinkDirectoryCategory;
|
||||
|
||||
const FILTER_ITEMS: ReadonlyArray<{ id: CategoryFilter; label: string }> = [
|
||||
{ id: "all", label: "Tümü" },
|
||||
{ id: "all", label: "Tüm kategoriler" },
|
||||
...LINK_DIRECTORY_CATEGORIES,
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS = Object.fromEntries(
|
||||
LINK_DIRECTORY_CATEGORIES.map((item) => [item.id, item.label]),
|
||||
) as Record<(typeof LINK_DIRECTORY_CATEGORIES)[number]["id"], string>;
|
||||
|
||||
const CATEGORY_ORDER = {
|
||||
resources: 0,
|
||||
navigation: 1,
|
||||
social: 2,
|
||||
} 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;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label, "tr");
|
||||
});
|
||||
|
||||
function normalize(value: string) {
|
||||
return value
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
@@ -35,135 +54,226 @@ function normalize(value: string) {
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: LinkDirectoryCategory) {
|
||||
return LINK_DIRECTORY_CATEGORIES.find((item) => item.id === category)?.label ?? "Kategori";
|
||||
function formatHref(href: string) {
|
||||
if (href.startsWith("mailto:")) {
|
||||
return href.replace("mailto:", "");
|
||||
}
|
||||
|
||||
if (href.startsWith("/")) {
|
||||
return `poyrazavsever.com${href}`;
|
||||
}
|
||||
|
||||
return href.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function LinksContent() {
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const filteredLinks = useMemo(() => {
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalizedQuery = normalize(query.trim());
|
||||
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
|
||||
return LINK_DIRECTORY.filter((item) => {
|
||||
const categoryMatch = activeCategory === "all" || item.category === activeCategory;
|
||||
if (!categoryMatch) return false;
|
||||
if (queryTokens.length === 0) return true;
|
||||
return DIRECTORY_ITEMS.filter((item) => {
|
||||
if (activeCategory !== "all" && item.category !== activeCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const haystack = normalize([
|
||||
item.label,
|
||||
item.href,
|
||||
...item.keywords,
|
||||
getCategoryLabel(item.category),
|
||||
].join(" "));
|
||||
if (queryTokens.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = normalize(
|
||||
[item.label, item.href, CATEGORY_LABELS[item.category], ...item.keywords].join(" "),
|
||||
);
|
||||
|
||||
return queryTokens.every((token) => haystack.includes(token));
|
||||
});
|
||||
}, [activeCategory, query]);
|
||||
|
||||
const handleCopy = async (id: string, href: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(href);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => {
|
||||
setCopiedId((current) => (current === id ? null : current));
|
||||
}, 1400);
|
||||
} catch {
|
||||
setCopiedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex h-full flex-col gap-3 overflow-y-auto">
|
||||
<Card className="rounded-sm border-border p-4">
|
||||
<CardHeader className="space-y-1 p-0">
|
||||
<CardTitle className="text-xl">Link Merkezi</CardTitle>
|
||||
<CardDescription>
|
||||
Tüm sosyal medya, sayfa ve kaynak linklerini tek yerden ara, filtrele ve yönet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<section className="relative isolate min-h-dvh overflow-hidden px-4 py-8 sm:px-6 sm:py-10">
|
||||
|
||||
<CardContent className="mt-4 space-y-3 p-0">
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Linklerde ara... (örn: github, medium, /blog)"
|
||||
aria-label="Linklerde arama"
|
||||
<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"
|
||||
/>
|
||||
<Badge variant="outline" className="inline-flex h-10 items-center px-3">
|
||||
{filteredLinks.length} / {LINK_DIRECTORY.length} sonuç
|
||||
</Badge>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-background/20 via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeCategory}
|
||||
onValueChange={(value) => setActiveCategory(value as CategoryFilter)}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4">
|
||||
{FILTER_ITEMS.map((item) => (
|
||||
<TabsTrigger key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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.jpeg" alt="Poyraz Avsever" />
|
||||
<AvatarFallback className="rounded-3xl bg-muted text-lg font-semibold">
|
||||
PA
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{filteredLinks.length === 0 ? (
|
||||
<Card className="rounded-sm border-border p-5">
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
Arama kriterine uygun link bulunamadı.
|
||||
<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>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{filteredLinks.map((item) => (
|
||||
<Card
|
||||
key={`${item.category}-${item.id}`}
|
||||
className="rounded-sm border-border p-3 transition-colors hover:border-zinc-700"
|
||||
<Typography
|
||||
variant="h2"
|
||||
secondaryFont
|
||||
className="text-[1.75rem] leading-none text-red-600 sm:text-[2rem]"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3 space-y-0 p-0">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-sm border border-border text-muted-foreground">
|
||||
<Icon icon={item.icon} width={18} height={18} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">{item.label}</CardTitle>
|
||||
<CardDescription className="truncate text-xs">{item.href}</CardDescription>
|
||||
Avsever
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{getCategoryLabel(item.category)}</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="mt-3 flex items-center gap-2 p-0">
|
||||
<Button asChild size="sm">
|
||||
<Typography variant="small" className="max-w-lg text-sm leading-6 text-muted-foreground">
|
||||
Burada sosyal hesaplarım, portfolyo sayfalarım ve hızlı erişim linklerimin
|
||||
tamamı tek yerde duruyor. Direkt açıp paylaşabilirsin.
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SOCIAL_LINKS.map((item) => (
|
||||
<Link
|
||||
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} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{TOP_ICON_LINKS.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noreferrer" : undefined}
|
||||
className="block"
|
||||
>
|
||||
Aç
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(item.id, item.href)}
|
||||
<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">
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="small"
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{copiedId === item.id ? "Kopyalandı" : "Kopyala"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
{formatHref(item.href)}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<div>
|
||||
<Typography variant="large" className="text-base">
|
||||
Tüm Linkler
|
||||
</Typography>
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
Sayfalar, kaynaklar ve sosyal profiller
|
||||
</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="Kategori seç" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Link ara... github, medium, /blog"
|
||||
aria-label="Linklerde arama"
|
||||
className="h-11 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{filteredItems.map((item) => (
|
||||
<Link
|
||||
key={`${item.category}-${item.id}`}
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noreferrer" : undefined}
|
||||
className="block"
|
||||
>
|
||||
<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">
|
||||
{item.label}
|
||||
</Typography>
|
||||
<span className="inline-flex rounded-full border border-border px-2.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{CATEGORY_LABELS[item.category]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Typography
|
||||
variant="small"
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{formatHref(item.href)}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground">
|
||||
<Icon
|
||||
icon={item.external ? "mdi:arrow-top-right" : "mdi:arrow-right"}
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<Card className="rounded-2xl border-border px-4 py-5">
|
||||
<Typography variant="small" className="text-muted-foreground">
|
||||
Seçili filtreye uygun link bulunamadı.
|
||||
</Typography>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 116 KiB |
Reference in New Issue
Block a user