diff --git a/admin/src/routes/menus/+page.svelte b/admin/src/routes/menus/+page.svelte index 850616c0..dd282d43 100644 --- a/admin/src/routes/menus/+page.svelte +++ b/admin/src/routes/menus/+page.svelte @@ -11,20 +11,26 @@ const pageTitle = 'Menü Yönetimi | Admin Panel'; + // Backend Menu model: id, name, link, subMenu, icon, active, order, categories[], createdAt, updatedAt + interface MenuCategory { + id: number; + name: string; + } interface Menu { - id: string; + id: number; name: string; - slug: string; - url: string; + link: string; + subMenu: number; + icon?: string | null; + active: boolean; order: number; - isActive: boolean; - parentId?: string; - categoryId?: string; + categories?: MenuCategory[]; createdAt: string; updatedAt: string; } let menus: Menu[] = []; + let menuCategories: MenuCategory[] = []; let loading = false; let isModalOpen = false; let isDeleteModalOpen = false; @@ -35,28 +41,28 @@ let totalItems = 0; const itemsPerPage = 10; - // Form data + // Form data (backend field names) let formData = { name: '', - slug: '', - url: '', + link: '', order: 0, - isActive: true, - parentId: '', - categoryId: '' + active: true, + subMenu: 0, + icon: '', + categoryIds: [] as number[] }; const columns = [ { key: 'name', label: 'Menü Adı' }, - { key: 'slug', label: 'Slug' }, - { key: 'url', label: 'URL' }, + { key: 'link', label: 'URL' }, { key: 'order', label: 'Sıra' }, - { key: 'isActive', label: 'Aktif' }, + { key: 'active', label: 'Aktif' }, { key: 'createdAt', label: 'Oluşturulma' } ]; onMount(() => { loadMenus(); + loadMenuCategories(); }); async function loadMenus() { @@ -64,8 +70,8 @@ try { const response = await getData('menus', false, itemsPerPage, currentPage); menus = response.data || []; - totalItems = (response.meta as any)?.total || menus.length; - totalPages = Math.ceil(totalItems / itemsPerPage); + totalItems = (response.meta as any)?.total ?? menus.length; + totalPages = Math.ceil(totalItems / itemsPerPage) || 1; } catch (error) { toastStore.add('error', 'Menüler yüklenirken hata oluştu'); } finally { @@ -73,16 +79,25 @@ } } + async function loadMenuCategories() { + try { + const response = await getData('menu-categories', false, 100, 1); + menuCategories = response.data || []; + } catch { + menuCategories = []; + } + } + function openAddModal() { editingMenu = null; formData = { name: '', - slug: '', - url: '', + link: '', order: 0, - isActive: true, - parentId: '', - categoryId: '' + active: true, + subMenu: 0, + icon: '', + categoryIds: [] }; isModalOpen = true; } @@ -91,12 +106,12 @@ editingMenu = menu; formData = { name: menu.name, - slug: menu.slug, - url: menu.url, + link: menu.link, order: menu.order, - isActive: menu.isActive, - parentId: menu.parentId || '', - categoryId: menu.categoryId || '' + active: menu.active, + subMenu: menu.subMenu ?? 0, + icon: menu.icon || '', + categoryIds: menu.categories?.map((c) => c.id) ?? [] }; isModalOpen = true; } @@ -116,18 +131,32 @@ deletingMenu = null; } + function toggleCategory(id: number) { + const idx = formData.categoryIds.indexOf(id); + if (idx === -1) { + formData.categoryIds = [...formData.categoryIds, id]; + } else { + formData.categoryIds = formData.categoryIds.filter((x) => x !== id); + } + } + async function handleSubmit(e: Event) { e.preventDefault(); loading = true; try { - const data: any = { ...formData }; - // Remove empty optional fields - if (!data.parentId) delete data.parentId; - if (!data.categoryId) delete data.categoryId; + const payload = { + name: formData.name.trim(), + link: formData.link.trim(), + order: Number(formData.order) || 0, + active: formData.active, + subMenu: Number(formData.subMenu) || 0, + icon: formData.icon.trim() || null, + categoryIds: formData.categoryIds + }; if (editingMenu) { - const result = await updateData('menus', editingMenu.id, data); + const result = await updateData('menus', editingMenu.id, payload); if (result.success) { toastStore.add('success', 'Menü başarıyla güncellendi'); closeModal(); @@ -136,7 +165,7 @@ toastStore.add('error', result.message || 'Güncelleme başarısız'); } } else { - const result = await addData('menus', data); + const result = await addData('menus', payload); if (result.success) { toastStore.add('success', 'Menü başarıyla eklendi'); closeModal(); @@ -176,21 +205,6 @@ currentPage = page; loadMenus(); } - - function generateSlug() { - if (formData.name) { - formData.slug = formData.name - .toLowerCase() - .replace(/ğ/g, 'g') - .replace(/ü/g, 'u') - .replace(/ş/g, 's') - .replace(/ı/g, 'i') - .replace(/ö/g, 'o') - .replace(/ç/g, 'c') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - } - } @@ -245,32 +259,15 @@ bind:value={formData.name} required placeholder="Ana Sayfa, Hakkımızda, vb." - on:blur={generateSlug} /> -
- -
- -
-
-
@@ -279,42 +276,58 @@ name="order" label="Sıra" bind:value={formData.order} - required placeholder="0" /> -
- +
+
-
-

- İleri Düzey Ayarlar (Opsiyonel) -

-
- - -
+
+ +
+ {#if menuCategories.length > 0} +
+

Kategoriler

+
+ {#each menuCategories as cat} + + {/each} +
+
+ {/if} +
diff --git a/backend/src/services/menu.service.ts b/backend/src/services/menu.service.ts index 23c7f535..77204fea 100644 --- a/backend/src/services/menu.service.ts +++ b/backend/src/services/menu.service.ts @@ -19,10 +19,7 @@ class MenuService { } } : true, }, - orderBy: { - // order by id in ascending order - id: 'asc', - }, + orderBy: [{ order: "asc" }, { id: "asc" }], }); return { menus, count }; @@ -52,12 +49,20 @@ class MenuService { public async createMenu(menuData: any): Promise { try { + const categoryIds = Array.isArray(menuData.categoryIds) + ? menuData.categoryIds.filter((id: number) => Number.isInteger(id)) + : []; const menu = await this.prisma.menu.create({ data: { name: menuData.name, link: menuData.link, - subMenu: menuData.subMenu || 0, - icon: menuData.icon, + subMenu: menuData.subMenu ?? 0, + icon: menuData.icon ?? null, + active: menuData.active !== false, + order: typeof menuData.order === "number" ? menuData.order : 0, + ...(categoryIds.length > 0 && { + categories: { connect: categoryIds.map((id: number) => ({ id })) }, + }), }, }); @@ -69,14 +74,23 @@ class MenuService { public async updateMenu(id: number, menuData: any): Promise { try { + const categoryIds = Array.isArray(menuData.categoryIds) + ? menuData.categoryIds.filter((id: number) => Number.isInteger(id)) + : undefined; + const data: any = { + name: menuData.name, + link: menuData.link, + subMenu: menuData.subMenu ?? 0, + icon: menuData.icon ?? null, + active: menuData.active !== false, + order: typeof menuData.order === "number" ? menuData.order : 0, + }; + if (categoryIds !== undefined) { + data.categories = { set: categoryIds.map((id: number) => ({ id })) }; + } const menu = await this.prisma.menu.update({ where: { id }, - data: { - name: menuData.name, - link: menuData.link, - subMenu: menuData.subMenu, - icon: menuData.icon, - }, + data, }); return menu; diff --git a/frontend/src/app/arama/page.tsx b/frontend/src/app/arama/page.tsx index cb81f2b9..1c508f10 100644 --- a/frontend/src/app/arama/page.tsx +++ b/frontend/src/app/arama/page.tsx @@ -1,14 +1,29 @@ import React from "react"; import SideMenu from "@/components/Nav/SideMenu"; -import SearchInput from "@/components/SearchInput"; -import { SearchResults } from "@/components/Search/SearchResults"; +import SearchPageContentWithResults from "@/components/Search/SearchPageContent"; import { searchPostsByTerm } from "@/services/searchService"; +import type { IPost } from "@/types/types"; export const metadata = { title: "Ekşicode - Arama Sonuçları", description: "Ekşicode gönderi arama sonuçları", }; +const LATEST_POSTS_LIMIT = 10; + +interface FetchPostsResponse { + data: IPost[]; + meta: { + total: number; + skip: number; + limit: number; + currentPage: number; + totalPages: number; + summaryOnly?: boolean; + }; + message: string; +} + interface SearchPageProps { searchParams: Promise<{ q?: string; @@ -18,40 +33,42 @@ interface SearchPageProps { export default async function SearchPage({ searchParams }: SearchPageProps) { const resolvedSearchParams = await searchParams; - const searchTerm = resolvedSearchParams.q || ''; - const page = resolvedSearchParams.page ? parseInt(resolvedSearchParams.page, 10) : 1; + const searchTerm = resolvedSearchParams.q || ""; + const page = resolvedSearchParams.page + ? parseInt(resolvedSearchParams.page, 10) + : 1; - // If no search term, show search interface - if (!searchTerm.trim()) { - return ( -
-
- -
-
-
-

- Gönderi Arama -

-
- -
-

- Kapsamlı arama: yazı başlıkları, içerikleri, yazar adları, kategoriler ve etiketler arasında arama yapabilirsiniz. -

-
-
-
- ); - } + let latestPosts: IPost[] = []; + let latestMeta: { total: number; totalPages: number } | undefined; + let searchResults = null; - // Fetch search results - const searchResults = await searchPostsByTerm(searchTerm, page, 10, { - cache: "no-store" // Don't cache search results - }); + if (searchTerm.trim()) { + searchResults = await searchPostsByTerm(searchTerm, page, 10, { + cache: "no-store", + }); + } else { + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (apiUrl) { + const res = await fetch( + `${apiUrl}/posts?summaryOnly=true&limit=${LATEST_POSTS_LIMIT}&skip=0`, + { cache: "no-store" } + ); + if (res.ok) { + const json: FetchPostsResponse = await res.json(); + latestPosts = Array.isArray(json.data) ? json.data : []; + if (json.meta) { + latestMeta = { + total: json.meta.total, + totalPages: json.meta.totalPages, + }; + } + } + } + } catch { + // Fallback to empty list + } + } return (
@@ -59,38 +76,13 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
-
-

- Arama Sonuçları -

-
-

- Arama Terimi: "{searchTerm}" -

- {searchResults.meta.total > 0 && ( -

- {searchResults.meta.total} sonuç bulundu -

- )} -
- - {/* Search Input for refining search */} -
- -
-
- -
- -
+
); diff --git a/frontend/src/app/etiketler/page.tsx b/frontend/src/app/etiketler/page.tsx index 4c77fd9c..405cddc1 100644 --- a/frontend/src/app/etiketler/page.tsx +++ b/frontend/src/app/etiketler/page.tsx @@ -1,4 +1,5 @@ import React from "react"; +import type { Metadata } from "next"; import Link from "next/link"; import SideMenu from "@/components/Nav/SideMenu"; import TagsCard from "@/components/TagsCard"; @@ -9,6 +10,9 @@ import EtiketlerSearch from "@/components/EtiketlerSearch"; import { getAllHashtags, searchHashtags } from "@/services/hashtagService"; import type { Hashtag } from "@/types/hashtag.types"; +/** Always render on the server per request; no static generation or cache. */ +export const dynamic = "force-dynamic"; + interface SearchParams { page?: string; limit?: string; @@ -19,6 +23,23 @@ interface TagsPageProps { searchParams: Promise; } +export async function generateMetadata({ + searchParams, +}: TagsPageProps): Promise { + const resolved = await searchParams; + const searchQuery = resolved.search?.trim() || ""; + const title = searchQuery + ? `Etiketler - "${searchQuery}" araması` + : "Etiketler"; + const description = searchQuery + ? `"${searchQuery}" için etiket arama sonuçları.` + : "Tüm etiketler listesi."; + return { + title, + description, + }; +} + export default async function Tags({ searchParams }: TagsPageProps) { // Extract pagination parameters from search params const resolvedSearchParams = await searchParams; @@ -26,12 +47,10 @@ export default async function Tags({ searchParams }: TagsPageProps) { const limit = resolvedSearchParams.limit ? parseInt(resolvedSearchParams.limit, 10) : 50; const searchQuery = resolvedSearchParams.search || ""; - // Fetch hashtags using the service + // SSR: fetch from backend on every request (no static/cache) const hashtagsResponse = searchQuery ? await searchHashtags(searchQuery, page, limit) - : await getAllHashtags(page, limit, { - next: { revalidate: 43200 }, // 12 hours cache - }); + : await getAllHashtags(page, limit, { cache: "no-store" }); // Show error state if no data if (!hashtagsResponse.data || hashtagsResponse.data.length === 0) { @@ -44,8 +63,9 @@ export default async function Tags({ searchParams }: TagsPageProps) {

Etiket Bulunamadı

- Henüz hiç etiket eklenmemiş veya veriler yüklenirken bir hata - oluştu. + {searchQuery + ? `"${searchQuery}" araması için sonuç bulunamadı.` + : "Henüz hiç etiket eklenmemiş veya veriler yüklenirken bir hata oluştu."}

@@ -90,7 +110,7 @@ export default async function Tags({ searchParams }: TagsPageProps) { ))}
- {/* Pagination */} + {/* Pagination — preserve search in page links for SSR */} {hashtagsResponse.meta && ( ; } +export async function generateMetadata({ + searchParams, +}: SourcesPageProps): Promise { + const resolved = await searchParams; + const searchQuery = resolved.search?.trim() || ""; + const title = searchQuery + ? `Kaynaklar - "${searchQuery}" araması` + : "Kaynaklar"; + const description = searchQuery + ? `"${searchQuery}" için kaynak arama sonuçları.` + : "Onaylı içerik kaynakları listesi."; + return { + title, + description, + }; +} + export default async function Sources({ searchParams }: SourcesPageProps) { // Extract pagination parameters from search params const resolvedSearchParams = await searchParams; @@ -25,12 +44,10 @@ export default async function Sources({ searchParams }: SourcesPageProps) { const limit = resolvedSearchParams.limit ? parseInt(resolvedSearchParams.limit, 10) : 10; const searchQuery = resolvedSearchParams.search || ""; - // Fetch sources using the service + // SSR: fetch from backend on every request (no static/cache) const sourcesData = searchQuery ? await searchSources(searchQuery, page, limit) - : await getAllSources(page, limit, { - cache: "no-store", - }); + : await getAllSources(page, limit, { cache: "no-store" }); const formatHashtags = (tags: Hashtag[]): string => { return tags.map(hashtag => "#" + hashtag.name).join(", "); @@ -93,9 +110,13 @@ export default async function Sources({ searchParams }: SourcesPageProps) { onDelete={false} /> - {/* Pagination */} + {/* Pagination — preserve search in page links for SSR */} {sourcesData.meta && ( - + )} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 75c82d8f..436a7762 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -14,7 +14,10 @@ const roboto = Roboto({ display: "swap", // Optimize font loading }); +const siteUrl = process.env.NEXT_PUBLIC_URL || "https://eksicode.org"; + export const metadata: Metadata = { + metadataBase: new URL(siteUrl), title: "Ekşicode - Yazılımcı Geliştirme Platformu", description: "Telegram grupları üzerinden her seviyeden yazılım geliştiricinin ve yazılım öğrenmek isteyen kişilerin bir araya geldiği platform.", diff --git a/frontend/src/app/posts/[post]/page.tsx b/frontend/src/app/posts/[post]/page.tsx index dbd05ea0..19c36b61 100644 --- a/frontend/src/app/posts/[post]/page.tsx +++ b/frontend/src/app/posts/[post]/page.tsx @@ -6,10 +6,12 @@ import type { Metadata } from "next"; import DOMPurify from "isomorphic-dompurify"; import { IPost, IComment } from "@/types/types"; import { formatDate } from "@/utils/utils"; +import { plainTextExcerpt } from "@/utils/seo"; import { AiOutlineLike, AiOutlineMessage } from "react-icons/ai"; import { BiBookmarkAltPlus } from "react-icons/bi"; import { BsEye } from "react-icons/bs"; import ppImage from "../../../../public/images/pp-image.png"; +import Link from "next/link"; // API response wrapper type interface PostResponse { @@ -22,8 +24,11 @@ interface PostPageParams { params: Promise<{ post: string }>; } +/** Always render on the server per request; fresh data for SEO and view count. */ +export const dynamic = "force-dynamic"; + /** - * Generate metadata for SEO + * Generate metadata for SEO (fresh fetch, plain-text description, canonical, OG) */ export async function generateMetadata({ params }: PostPageParams): Promise { const { post: slug } = await params; @@ -31,7 +36,7 @@ export async function generateMetadata({ params }: PostPageParams): Promise tag.name) || [], + authors: postData.author?.username ? [postData.author.username] : undefined, + tags: postData.tags?.map((tag) => tag.name) || [], images: imageUrl ? [{ url: imageUrl }] : [], }, twitter: { card: "summary_large_image", title: postData.title, - description: postData.content?.substring(0, 160) || "", + description: description || postData.title, images: imageUrl ? [imageUrl] : [], }, + robots: "index, follow", }; } catch (error) { console.error("Error generating metadata:", error); @@ -83,13 +92,9 @@ export default async function PostDetailPage({ params }: PostPageParams) { let postData: IPost; try { - // Fetch post data with view count increment const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/posts/${slug}?incrementView=true`, - { - cache: "no-store", // Don't cache to ensure view count updates - next: { revalidate: 60 }, // Revalidate every minute for fresh data - } + { cache: "no-store" } ); if (response.status === 404) { @@ -142,7 +147,7 @@ export default async function PostDetailPage({ params }: PostPageParams) { {/* Main Content */}
-
+
{/* Post Card */}
{/* Author Info */} @@ -189,12 +194,13 @@ export default async function PostDetailPage({ params }: PostPageParams) { {postData.categories && postData.categories.length > 0 && (
{postData.categories.map((category) => ( - {category.name} - + ))}
)} @@ -203,12 +209,13 @@ export default async function PostDetailPage({ params }: PostPageParams) { {postData.tags && postData.tags.length > 0 && (
{postData.tags.map((tag) => ( - #{tag.name} - + ))}
)} diff --git a/frontend/src/app/posts/page.tsx b/frontend/src/app/posts/page.tsx index 08a1f212..553c69d7 100644 --- a/frontend/src/app/posts/page.tsx +++ b/frontend/src/app/posts/page.tsx @@ -1,12 +1,60 @@ -import React, { Suspense } from 'react'; -import Posts from '@/components/Posts'; -import SideMenu from '@/components/Nav/SideMenu'; -import type { Metadata } from 'next'; +import React, { Suspense } from "react"; +import Posts from "@/components/Posts"; +import SideMenu from "@/components/Nav/SideMenu"; +import PostsSearch from "@/components/PostsSearch"; +import { PostCard } from "@/components/PostCard"; +import Pagination from "@/components/Pagination"; +import type { Metadata } from "next"; +import type { IPost } from "@/types/types"; +import { searchPostsByTerm } from "@/services/searchService"; -export const metadata: Metadata = { - title: 'Tüm Gönderiler - EksiCode', - description: 'EksiCode platformundaki tüm gönderileri keşfedin.', -}; +/** Always render on the server per request; no static generation or cache. */ +export const dynamic = "force-dynamic"; + +const POSTS_LIST_LIMIT = 5; +const POSTS_SEARCH_LIMIT = 10; + +interface PostsPageProps { + searchParams: Promise<{ q?: string; page?: string }>; +} + +export async function generateMetadata({ searchParams }: PostsPageProps): Promise { + const resolved = await searchParams; + const q = resolved.q?.trim() || ""; + const title = q ? `"${q}" araması - Gönderiler - EksiCode` : "Tüm Gönderiler - EksiCode"; + const description = q + ? `"${q}" için gönderi arama sonuçları.` + : "EksiCode platformundaki tüm gönderileri keşfedin."; + return { + title, + description, + openGraph: { + title, + description, + type: "website", + url: q ? `/posts?q=${encodeURIComponent(q)}` : "/posts", + }, + twitter: { + card: "summary_large_image", + title, + description, + }, + alternates: { canonical: q ? `/posts?q=${encodeURIComponent(q)}` : "/posts" }, + }; +} + +interface FetchPostsResponse { + data: IPost[]; + meta: { + total: number; + skip: number; + limit: number; + currentPage: number; + totalPages: number; + summaryOnly?: boolean; + }; + message: string; +} /** * Posts loading skeleton @@ -42,30 +90,129 @@ function PostsLoading() { } /** - * Posts Page - Display all posts + * Posts Page - SSR with optional search (q) or list + load more */ -export default function PostsPage() { +export default async function PostsPage({ searchParams }: PostsPageProps) { + const resolved = await searchParams; + const searchQuery = resolved.q?.trim() || ""; + const page = resolved.page ? parseInt(resolved.page, 10) : 1; + + // Search mode: fetch search results from API + if (searchQuery) { + const searchResults = await searchPostsByTerm( + searchQuery, + page, + POSTS_SEARCH_LIMIT, + { cache: "no-store" } + ); + const { posts, meta } = searchResults; + + return ( +
+
+ +
+ +
+
+
+
+

+ Gönderiler + {meta.total > 0 && ( + + ({meta.total} sonuç) + + )} +

+ {searchQuery && ( + + "{searchQuery}" için sonuçlar + + )} +
+ +
+
+ + {!posts || posts.length === 0 ? ( +
+

+ "{searchQuery}" için sonuç bulunamadı. Farklı anahtar kelimeler deneyin. +

+
+ ) : ( + <> +
+ {posts.map((post: IPost) => ( + + ))} +
+ {meta.totalPages > 1 && ( + + )} + + )} +
+
+ ); + } + + // List mode: SSR initial posts, then load more on client + let initialPosts: IPost[] = []; + let initialMeta: { total: number; totalPages: number } | undefined; + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (apiUrl) { + const res = await fetch( + `${apiUrl}/posts?summaryOnly=true&limit=${POSTS_LIST_LIMIT}&skip=0`, + { cache: "no-store" } + ); + if (res.ok) { + const json: FetchPostsResponse = await res.json(); + initialPosts = Array.isArray(json.data) ? json.data : []; + if (json.meta) { + initialMeta = { + total: json.meta.total, + totalPages: json.meta.totalPages, + }; + } + } + } + } catch { + // Fallback to client-side fetch only + } + return (
- {/* Left Sidebar - Menu */}
- {/* Main Content Area */}
-
-

- Tüm Gönderiler -

-

- EksiCode topluluğunun paylaştığı tüm gönderileri keşfedin. -

+
+
+
+

+ Tüm Yazılar +

+

+ EksiCode topluluğunun paylaştığı tüm gönderileri keşfedin. +

+
+ +
- {/* Posts Section with Loading State */} }> - +
diff --git a/frontend/src/components/Nav/Nav.tsx b/frontend/src/components/Nav/Nav.tsx index 6675fb76..9e9e8d9c 100644 --- a/frontend/src/components/Nav/Nav.tsx +++ b/frontend/src/components/Nav/Nav.tsx @@ -51,7 +51,7 @@ const Nav = () => { e.currentTarget.style.display = "none"; }} /> - + EkşiCode diff --git a/frontend/src/components/Posts.tsx b/frontend/src/components/Posts.tsx index 43197e04..8d180d59 100644 --- a/frontend/src/components/Posts.tsx +++ b/frontend/src/components/Posts.tsx @@ -8,6 +8,8 @@ import { getUserFacingMessage, getErrorFromStatus } from "@/utils/errors"; interface PostsProps { initialPosts?: IPost[]; + /** When using SSR, pass meta from initial fetch so total/hasMore are correct on first paint */ + initialMeta?: { total: number; totalPages: number }; } interface FetchPostsResponse { @@ -23,12 +25,14 @@ interface FetchPostsResponse { message: string; } -const Posts: React.FC = ({ initialPosts = [] }) => { +const Posts: React.FC = ({ initialPosts = [], initialMeta }) => { const [postItems, setPostItems] = useState(initialPosts); const [skip, setSkip] = useState(initialPosts.length); - const [hasMore, setHasMore] = useState(true); + const [hasMore, setHasMore] = useState( + initialMeta ? 1 < initialMeta.totalPages : true + ); const [loading, setLoading] = useState(false); - const [totalPosts, setTotalPosts] = useState(0); + const [totalPosts, setTotalPosts] = useState(initialMeta?.total ?? 0); const [error, setError] = useState(""); const [retryCount, setRetryCount] = useState(0); diff --git a/frontend/src/components/PostsSearch.tsx b/frontend/src/components/PostsSearch.tsx new file mode 100644 index 00000000..4047f405 --- /dev/null +++ b/frontend/src/components/PostsSearch.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; + +interface PostsSearchProps { + placeholder?: string; + className?: string; + defaultValue?: string; +} + +/** + * Search input that navigates to /posts?q=... (stays on posts page). + */ +const PostsSearch: React.FC = ({ + placeholder = "Yazı ara: başlık, içerik, yazar, etiket...", + className = "", + defaultValue = "", +}) => { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(defaultValue); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const params = new URLSearchParams(); + if (searchQuery.trim()) { + params.set("q", searchQuery.trim()); + router.push(`/posts?${params.toString()}`); + } else { + router.push("/posts"); + } + }; + + return ( +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="block w-full p-3 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" + placeholder={placeholder} + /> + {searchQuery && ( + + )} +
+ +
+ ); +}; + +export default PostsSearch; diff --git a/frontend/src/components/Search/SearchPageContent.tsx b/frontend/src/components/Search/SearchPageContent.tsx new file mode 100644 index 00000000..58fd727f --- /dev/null +++ b/frontend/src/components/Search/SearchPageContent.tsx @@ -0,0 +1,250 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { IPost } from "@/types/types"; +import { PostCard } from "@/components/PostCard"; +import { SearchResults } from "@/components/Search/SearchResults"; +import type { SearchResponse } from "@/services/searchService"; +import type { PaginationMeta } from "@/types/hashtag.types"; + +const SEARCH_PLACEHOLDER = + "Başlık, içerik, yazar, kategori veya etiketlerde ara..."; + +interface SearchPageContentProps { + /** Current search query (empty when showing latest posts). */ + searchTerm: string; + /** When searchTerm is set: search API response. */ + searchResults?: SearchResponse | null; + /** When searchTerm is set: current page number. */ + currentPage?: number; + /** When searchTerm is empty: latest posts to list. */ + latestPosts?: IPost[]; + /** When searchTerm is empty: pagination meta for latest posts. */ + latestMeta?: { total: number; totalPages: number }; +} + +export function SearchPageContent({ + searchTerm, + searchResults = null, + currentPage = 1, + latestPosts = [], + latestMeta, +}: SearchPageContentProps) { + const router = useRouter(); + const [query, setQuery] = useState(searchTerm); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (trimmed) { + router.push(`/arama?q=${encodeURIComponent(trimmed)}`); + } else { + router.push("/arama"); + } + }; + + const hasSearch = searchTerm.trim().length > 0; + + return ( +
+

+ {hasSearch ? "Arama Sonuçları" : "Gönderi Arama"} +

+ + {/* Search form – project colors (eksiCode) */} +
+
+
+
+ +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch(e)} + className="block w-full p-3 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-2 focus:ring-eksiCode focus:border-eksiCode dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-eksiCode dark:focus:border-eksiCode" + placeholder={SEARCH_PLACEHOLDER} + /> + {query && ( + + )} +
+ +
+
+ + {hasSearch ? ( + <> + {searchTerm && ( +
+

+ Arama Terimi:{" "} + "{searchTerm}" +

+ {searchResults && searchResults.meta.total > 0 && ( +

+ {searchResults.meta.total} sonuç bulundu +

+ )} +
+ )} + + ) : ( +

+ Kapsamlı arama: yazı başlıkları, içerikleri, yazar adları, kategoriler + ve etiketler arasında arama yapabilirsiniz. Arama yapmadan önce en + son gönderiler aşağıda listelenir. +

+ )} +
+ ); +} + +interface SearchPageContentWithResultsProps { + searchTerm: string; + searchResults?: SearchResponse | null; + currentPage?: number; + latestPosts?: IPost[]; + latestMeta?: { total: number; totalPages: number }; +} + +/** Wrapper that renders SearchPageContent plus results or latest posts list. */ +export function SearchPageContentWithResults({ + searchTerm, + searchResults = null, + currentPage = 1, + latestPosts = [], + latestMeta, +}: SearchPageContentWithResultsProps) { + const hasSearch = searchTerm.trim().length > 0; + + return ( + <> + + +
+ {hasSearch && searchResults ? ( + + ) : ( + + )} +
+ + ); +} + +interface LatestPostsListProps { + posts: IPost[]; + meta?: { total: number; totalPages: number }; +} + +function LatestPostsList({ posts, meta }: LatestPostsListProps) { + if (!posts || posts.length === 0) { + return ( +
+ Henüz gönderi bulunmuyor. +
+ ); + } + + return ( +
+
+

+ Son Gönderiler +

+ {meta && meta.total > posts.length && ( + + Tüm gönderiler ({meta.total}) + + )} +
+
+ {posts.map((post: IPost) => ( + + ))} +
+ {meta && meta.totalPages > 1 && ( +
+ + Tüm gönderileri görüntüle + +
+ )} +
+ ); +} + +// Default export for Next.js client boundary (fixes "promise resolves to undefined" lazy load error) +export default SearchPageContentWithResults; diff --git a/frontend/src/components/Search/SearchResults.tsx b/frontend/src/components/Search/SearchResults.tsx index bc3735bd..be21a6ca 100644 --- a/frontend/src/components/Search/SearchResults.tsx +++ b/frontend/src/components/Search/SearchResults.tsx @@ -69,9 +69,10 @@ export function SearchResults({ searchResults, searchTerm, currentPage }: Search {/* Pagination */} {meta.totalPages > 1 && ( - )}
diff --git a/frontend/src/components/TelegramGroupCard.tsx b/frontend/src/components/TelegramGroupCard.tsx index 46000ad4..4d417f01 100644 --- a/frontend/src/components/TelegramGroupCard.tsx +++ b/frontend/src/components/TelegramGroupCard.tsx @@ -23,10 +23,10 @@ const TelegramGroupCard = (group: Group) => { height="80" />
-

+

{group.name}

-

+

{group.members} Üye

diff --git a/frontend/src/services/hashtagService.ts b/frontend/src/services/hashtagService.ts index 743e9848..65b71b5f 100644 --- a/frontend/src/services/hashtagService.ts +++ b/frontend/src/services/hashtagService.ts @@ -161,7 +161,7 @@ export const searchHashtags = async ( limit: String(limit), }); const url = `${apiUrl}/hashtags/search?${params.toString()}`; - const response = await fetch(url, { next: { revalidate: 60 } }); + const response = await fetch(url, { cache: "no-store" }); const json = await response.json(); if (!response.ok) { diff --git a/frontend/src/utils/seo.ts b/frontend/src/utils/seo.ts new file mode 100644 index 00000000..e4a3ab68 --- /dev/null +++ b/frontend/src/utils/seo.ts @@ -0,0 +1,12 @@ +/** + * Strip HTML tags and truncate to a max length for meta descriptions. + * Keeps plain text only for SEO (search snippets, OG, Twitter). + */ +export function plainTextExcerpt(html: string | null | undefined, maxLength = 160): string { + if (!html || typeof html !== "string") return ""; + const plain = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + if (plain.length <= maxLength) return plain; + const truncated = plain.slice(0, maxLength - 3); + const lastSpace = truncated.lastIndexOf(" "); + return (lastSpace > maxLength / 2 ? truncated.slice(0, lastSpace) : truncated) + "..."; +}