Türkiye'nin Yeşil Şehir Dönüşümü: 2025 Hedefleri Açıklandı
Çevre ve Şehircilik Bakanlığı, 2025 yılına kadar 10 büyükşehirde yeşil alan oranını %40'a çıkarmayı hedefleyen kapsamlı planını açıkladı.
Magazine-style news card in five sizes — role-aware editor and viewer modes, permissions matrix, badges, paywall and sensitivity gates.
First component in the news-domain family (siblings: page-hero, magazine-layout, related-articles-ribbon-01 queued). v0.3.0 mirrors the post-card v0.3.2 trait set translated into editorial vocabulary (`editor` instead of `owner`; `publish/unpublish/feature` instead of `pin/markSensitive`; `paywall` distinct from `sensitive`). Strictly additive on v0.2: every v0.2.x consumer keeps working unchanged (all new fields/props/labels optional; existing `author` string + `date` field preserved alongside new structured `authorEntity` + `publishedAt`). Engagement-bar-01 is NOT a peer dep — consumers compose it via the `renderEngagementCounts` slot on news article detail pages per the documented integration pattern. F-S1 lock applied for cross-procomp imports (RELATIVE paths to specific files; `CommentMenuItem` reused from comment-thread). F-cross-13 defensive DropdownMenu pattern (direct trigger, no asChild). Migration origin: kasder kas-social-front-v0 NewsCard.tsx (v0.1 base; v0.3 expansion is greenfield).
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/news-cardAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/news-card-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}"use client"; import { useState } from "react";import { Bookmark, BookmarkCheck, Share2 } from "lucide-react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { NewsCard } from "./news-card";import { breakingNewsItem, draftItem, dummyCategoryStyles, dummyNewsCardItems, editorsPickItem, paywalledItem, quotingItem, sensitiveItem, sponsoredItem,} from "./dummy-data"; const items = dummyNewsCardItems; function CardActions({ itemId, bookmarked, onToggle,}: { itemId: string; bookmarked: boolean; onToggle: (id: string) => void;}) { return ( <div className="flex gap-2"> <Button variant="secondary" size="sm" onClick={(event) => { event.preventDefault(); event.stopPropagation(); onToggle(itemId); }} aria-label={bookmarked ? "Remove bookmark" : "Bookmark article"} > {bookmarked ? ( <BookmarkCheck aria-hidden="true" className="size-4" /> ) : ( <Bookmark aria-hidden="true" className="size-4" /> )} </Button> <Button variant="secondary" size="sm" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }} aria-label="Share article" > <Share2 aria-hidden="true" className="size-4" /> </Button> </div> );} export default function NewsCardDemo() { const [bookmarks, setBookmarks] = useState<Set<string>>(() => new Set()); const toggleBookmark = (id: string) => { setBookmarks((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const featured = items[0]; const large = items[1]; const mediums = items.slice(2, 6); const smalls = items.slice(2, 6); const lists = items.slice(0, 5); const log = (label: string) => () => console.log(`[news-card demo] ${label}`); return ( <Tabs defaultValue="featured" className="w-full"> <SwipeTabsList> <TabsTrigger value="featured">Featured</TabsTrigger> <TabsTrigger value="large">Large</TabsTrigger> <TabsTrigger value="medium">Medium</TabsTrigger> <TabsTrigger value="small">Small</TabsTrigger> <TabsTrigger value="list">List</TabsTrigger> <TabsTrigger value="composed">Composed</TabsTrigger> <TabsTrigger value="actions">Actions slot</TabsTrigger> <TabsTrigger value="editor">Editor mode</TabsTrigger> <TabsTrigger value="paywall">Paywall</TabsTrigger> <TabsTrigger value="sensitive">Sensitive</TabsTrigger> <TabsTrigger value="quoted">Quoted article</TabsTrigger> <TabsTrigger value="engagement">Engagement</TabsTrigger> </SwipeTabsList> <TabsContent value="featured" className="mt-6"> <NewsCard item={featured} variant="featured" href={`/news/${featured.id}`} categoryStyles={dummyCategoryStyles} /> </TabsContent> <TabsContent value="large" className="mt-6"> <NewsCard item={large} variant="large" href={`/news/${large.id}`} categoryStyles={dummyCategoryStyles} /> </TabsContent> <TabsContent value="medium" className="mt-6"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {mediums.map((item) => ( <NewsCard key={item.id} item={item} variant="medium" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} /> ))} </div> </TabsContent> <TabsContent value="small" className="mt-6"> <div className="grid gap-4 md:grid-cols-2"> {smalls.map((item) => ( <NewsCard key={item.id} item={item} variant="small" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} /> ))} </div> </TabsContent> <TabsContent value="list" className="mt-6"> <div className="rounded-2xl border border-border/50 bg-card p-4"> {lists.map((item) => ( <NewsCard key={item.id} item={item} variant="list" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} /> ))} </div> </TabsContent> <TabsContent value="composed" className="mt-6"> <div className="space-y-8"> <NewsCard item={featured} variant="featured" href={`/news/${featured.id}`} categoryStyles={dummyCategoryStyles} /> <div className="grid gap-6 lg:grid-cols-12"> <div className="space-y-6 lg:col-span-8"> <NewsCard item={large} variant="large" href={`/news/${large.id}`} categoryStyles={dummyCategoryStyles} /> <div className="grid gap-6 md:grid-cols-2"> {items.slice(2, 6).map((item) => ( <NewsCard key={item.id} item={item} variant="medium" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} /> ))} </div> </div> <aside className="lg:col-span-4"> <div className="rounded-2xl border border-border/50 bg-card p-4"> {items.slice(2, 6).map((item) => ( <NewsCard key={item.id} item={item} variant="list" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} /> ))} </div> </aside> </div> </div> </TabsContent> <TabsContent value="actions" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Click the bookmark or share buttons — they sit ABOVE the link overlay (z-10) and don't trigger card navigation. Click anywhere else on the card and you navigate normally. </p> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {items.slice(0, 3).map((item) => ( <NewsCard key={item.id} item={item} variant="medium" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} actions={ <CardActions itemId={item.id} bookmarked={bookmarks.has(item.id)} onToggle={toggleBookmark} /> } /> ))} </div> </TabsContent> <TabsContent value="editor" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> <strong>viewerMode="editor"</strong> — kebab shows editor actions (Edit / Publish / Schedule / Feature / Pin / Change visibility / Change category / Mark sensitive / See analytics / Delete). Draft status badge renders only in editor mode. Open the kebab on any card. </p> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {[editorsPickItem, draftItem, sponsoredItem].map((item) => ( <NewsCard key={item.id} item={item} variant="medium" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} viewerMode="editor" onEdit={log(`onEdit(${item.id})`)} onDelete={log(`onDelete(${item.id})`)} onPublish={log(`onPublish(${item.id})`)} onSchedule={log(`onSchedule(${item.id})`)} onFeature={log(`onFeature(${item.id})`)} onPin={log(`onPin(${item.id})`)} onChangeVisibility={log(`onChangeVisibility(${item.id})`)} onChangeCategory={log(`onChangeCategory(${item.id})`)} onMarkSensitive={log(`onMarkSensitive(${item.id})`)} onSeeAnalytics={log(`onSeeAnalytics(${item.id})`)} onShare={log(`onShare(${item.id})`)} onBookmark={log(`onBookmark(${item.id})`)} /> ))} </div> </TabsContent> <TabsContent value="paywall" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Premium content gate. Excerpt + media blurred behind a Subscribe CTA. Preview text shows above the gate. Open the console — clicking the CTA fires <code>onRevealPaywall</code> as an analytics hook. </p> <div className="grid gap-6 md:grid-cols-2"> <NewsCard item={paywalledItem} variant="medium" href={`/news/${paywalledItem.id}`} categoryStyles={dummyCategoryStyles} onRevealPaywall={log(`onRevealPaywall(${paywalledItem.id})`)} /> <NewsCard item={paywalledItem} variant="large" href={`/news/${paywalledItem.id}`} categoryStyles={dummyCategoryStyles} onRevealPaywall={log(`onRevealPaywall(${paywalledItem.id})`)} /> </div> </TabsContent> <TabsContent value="sensitive" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Sensitive content gate — media-only blur with reveal button. Distinct from paywall (different motivation). Lists content warnings when set. Reveal is per-session; reset via the handle's{" "} <code>reset(item)</code>. Small variant uses the gate's{" "} <code>compact</code> mode (icon + tiny "Show" pill) since its 96×96 thumb can't fit the full overlay. </p> <div className="space-y-6"> <NewsCard item={sensitiveItem} variant="medium" href={`/news/${sensitiveItem.id}`} categoryStyles={dummyCategoryStyles} onRevealSensitive={log(`onRevealSensitive(${sensitiveItem.id})`)} /> <NewsCard item={sensitiveItem} variant="small" href={`/news/${sensitiveItem.id}`} categoryStyles={dummyCategoryStyles} onRevealSensitive={log(`onRevealSensitive(${sensitiveItem.id})`)} /> </div> </TabsContent> <TabsContent value="quoted" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Analysis pieces quoting source articles render a nested compact mini-card (uses <code>variant="small"</code> internally for the horizontal thumb-left, body-right layout). Recursion-stripped (a quoted article's own <code>quotedArticle</code> is ignored), and the inner card's paywall + sensitive gates are suppressed so the quote stays a clean attribution. Renders in <code>medium</code>{" "} + <code>list</code> variants only per the per-variant feature matrix. </p> <div className="space-y-6"> <NewsCard item={quotingItem} variant="medium" href={`/news/${quotingItem.id}`} categoryStyles={dummyCategoryStyles} onQuotedClick={(q) => log(`onQuotedClick(${q.id})`)()} /> <NewsCard item={quotingItem} variant="list" href={`/news/${quotingItem.id}`} categoryStyles={dummyCategoryStyles} onQuotedClick={(q) => log(`onQuotedClick(${q.id})`)()} /> </div> </TabsContent> <TabsContent value="engagement" className="mt-6"> <p className="mb-4 text-sm text-muted-foreground"> Light engagement counts — like / comment / bookmark / share chips with handler-driven interactivity. For the news article{" "} <strong>detail page</strong>, consumers pass{" "} <code>renderEngagementCounts</code> to compose{" "} <code><EngagementBar></code> in this slot — see the description doc §6.2 for the integration pattern. <code>isLive</code> badge + updated-N-ago sub-line surface live-blog state. </p> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> {[breakingNewsItem, editorsPickItem, quotingItem].map((item) => ( <NewsCard key={item.id} item={item} variant="medium" href={`/news/${item.id}`} categoryStyles={dummyCategoryStyles} onLike={(id, nextLiked) => log(`onLike(${id}, ${nextLiked})`)()} onCommentCountClick={(id) => log(`onCommentCountClick(${id})`)()} onBookmark={(id, next) => log(`onBookmark(${id}, ${next})`)()} onShare={(id) => log(`onShare(${id})`)()} /> ))} </div> </TabsContent> </Tabs> );} Reach for NewsCard when you need a magazine-style article preview that flexes across different layout densities — a featured hero, a 2-col horizontal article, a vertical grid cell, a sidebar thumb, or a list row — all driven by a single variant prop. Built for news, blog, editorial, or documentation feeds.
import { NewsCard } from "@/components/news-card";
<NewsCard
item={{
id: "1",
title: "Headline",
image: "/cover.jpg",
excerpt: "Lead paragraph…",
category: "Sustainability",
author: "A. Yilmaz",
date: "2026-05-01",
readTime: 8,
}}
variant="medium"
href="/news/1"
categoryStyles={{
Sustainability: "bg-emerald-500/10 text-emerald-600",
}}
/>;featured — full-bleed hero card with image overlay, serif title, full meta row, and a Read-More CTA. The badge wears a bg-black/40 backdrop-blur-sm wrapper for legibility.large — 2-column horizontal card. Image left, serif title + excerpt + meta right. Good as the lead article in a main column under a featured hero.medium — vertical card with image-on-top, optional view-chip overlay, and a kicker footer (separator + author/date). The default workhorse for grid cells.small — compact horizontal thumb tile. Sidebar density; no excerpt, no actions slot.list — full-width row with badge + title + truncated excerpt + chevron. The chevron is replaced by the actions slot when provided.The card renders <a>by default. Pass your framework's link via linkComponent for SPA navigation:
import NextLink from "next/link";
<NewsCard
item={item}
href={`/news/${item.id}`}
linkComponent={NextLink}
/>;The card uses an overlay-link pattern: a real <a> covers the whole card via position: absolute; inset: 0. This keeps the entire surface clickable AND lets you embed independently-clickable buttons via the actions prop. The actions cluster gets position: relative; z-index: 10 so it sits above the overlay.
<NewsCard
item={item}
variant="medium"
href={`/news/${item.id}`}
actions={
<div className="flex gap-2">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
bookmark(item.id);
}}
aria-label="Bookmark"
>
<Bookmark />
</button>
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); share(item); }} aria-label="Share">
<Share2 />
</button>
</div>
}
/>;Inside the action button's onClick, call e.preventDefault() + e.stopPropagation()so the click doesn't bubble to the link overlay's default navigation. The Demo's "Actions slot" tab includes a working example.
<NewsCard
item={item}
variant="featured"
labels={{ readMore: "Devamını Oku", minutesRead: "dk okuma" }}
formatRelativeTime={(d) => myI18n.formatRelative(d)}
formatDate={(d) => d.toLocaleDateString("tr-TR", {
day: "numeric", month: "long", year: "numeric",
})}
/>;Only id, title, and image are required. Missing excerpt, category, author, date, readTime, or views are gracefully omitted — no empty placeholders, no layout glitches.
The title uses Tailwind's font-serif utility which maps to the pro-ui-wide --font-serif CSS variable (default: Playfair Display). Override at any DOM scope:
/* App-wide override */
:root { --font-serif: "Lora", Georgia, serif; }
/* Section-scoped override */
<section style={{ "--font-serif": '"Cormorant Garamond"' } as any}>
<NewsCard ... />
</section>
/* Per-card override */
<NewsCard
item={item}
titleClassName="font-sans tracking-tight"
/>;aria-labelledby; override with ariaLabel.:has(a:focus-visible)), not just the invisible link rectangle.aria-hidden.mediumannounces "N views" via aria-label.motion-safe: — reduced-motion users see static cards.