feat: refactor layout and links page, add AppShell component for improved structure

This commit is contained in:
Poyraz Avsever
2026-03-18 12:24:37 +03:00
parent 80da5424dc
commit 6627402267
5 changed files with 305 additions and 142 deletions
+54
View File
@@ -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>
</>
);
}
+217 -107
View File
@@ -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ı.
</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"
>
<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>
</div>
<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>
<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"
>
<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"
>
{formatHref(item.href)}
</Typography>
</div>
</div>
</Card>
</Link>
</Button>
))}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleCopy(item.id, item.href)}
>
{copiedId === item.id ? "Kopyalandı" : "Kopyala"}
</Button>
</CardContent>
</Card>
))}
</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>
);
}