Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=

# Firebase Admin SDK — required for server-side Firestore reads (layout, SSR).
# In local dev these can be left empty if serviceAccountKey.json is present.
# In production (Vercel etc.) set these from your Firebase service account:
# Firebase Console → Project Settings → Service Accounts → Generate new private key
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
# ─────────────────────────────────────────────────────────────────────────────
# .env.example — committed to git as documentation of all required variables.
#
Expand Down
2,135 changes: 2,135 additions & 0 deletions .firebase/logs/vsce-debug.log

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions app/admin/(protected)/branding/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import { useEffect, useState } from "react";
import { ImageUploadWithUrl } from "@/components/admin/shared/ImageUploadWithUrl";
import { getAdminSiteSettings, updateAdminSiteSettings } from "@/lib/firebase/adminServices";
import { revalidatePublicPathAction } from "@/app/admin/actions";

export default function BrandingPage() {
const [logoUrl, setLogoUrl] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");

useEffect(() => {
getAdminSiteSettings()
.then((s) => setLogoUrl(s.logoUrl ?? ""))
.catch(console.error)
.finally(() => setLoading(false));
}, []);

async function handleSave() {
setSaving(true);
setStatus("idle");
try {
await updateAdminSiteSettings({ logoUrl: logoUrl || null });
await revalidatePublicPathAction("/");
setStatus("success");
} catch (err) {
console.error(err);
setStatus("error");
} finally {
setSaving(false);
}
}

if (loading) {
return (
<div className="max-w-lg space-y-4">
<div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
<div className="h-32 animate-pulse rounded-xl bg-gray-200" />
</div>
);
}

return (
<div className="max-w-lg space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Site Logosu</h1>
<p className="mt-1 text-sm text-gray-500">
Sitenin başlığında görünecek logoyu yükleyin. Boş bırakılırsa varsayılan SVG logo gösterilir.
</p>
</div>

<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<ImageUploadWithUrl
label="Logo"
value={logoUrl}
onChange={setLogoUrl}
placeholder="https://…"
previewClassName="h-14 w-auto max-w-[240px] object-contain"
/>
</div>

<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary px-5 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-60"
>
{saving ? "Kaydediliyor…" : "Kaydet"}
</button>
{status === "success" && (
<span className="text-sm text-green-600">Logo kaydedildi.</span>
)}
{status === "error" && (
<span className="text-sm text-red-500">Hata oluştu, tekrar deneyin.</span>
)}
</div>
</div>
);
}
37 changes: 37 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,36 @@ import { headers } from 'next/headers'
import { unstable_cache } from 'next/cache'
import { Analytics } from '@vercel/analytics/next'
import { Providers } from './providers'
import { getSiteSettingsServer, getNavConfigServer } from '@/lib/firebase/serverServices'
import { getTenantByDomain } from '@/lib/tenant/masterDb'
import { TenantFirebaseProvider } from '@/lib/firebase/TenantFirebaseContext'
import { getTenantNavConfig, getTenantSiteSettings } from '@/lib/firebase/tenantServerDb'
import { DEFAULT_NAV_ITEMS } from '@/lib/firebase/navServices'
import { NavProvider } from '@/components/layout/NavProvider'
import { LogoProvider } from '@/components/layout/LogoProvider'
import { ThemeListener } from '@/components/theme/ThemeListener'
import './globals.css'

// Cache site settings for 5 minutes — they change rarely and don't need
// per-request freshness. The admin theme page calls revalidatePath('/') after
// saving to bust this cache immediately when settings are updated.
const getCachedSiteSettings = unstable_cache(
getSiteSettingsServer,
['site-settings'],
{ revalidate: 300 }
)

// Cache nav config for 60 seconds. The admin menu page calls
// revalidateNavAction() after every save, which calls revalidatePath("/","layout")
// to bust this cache immediately so changes propagate on the next request.
const getCachedNavConfig = unstable_cache(
getNavConfigServer,
['nav-config'],
{ revalidate: 60 }
)

const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });

export const metadata: Metadata = {
title: 'NGO Platform',
Expand Down Expand Up @@ -43,6 +65,12 @@ function buildCachedNavConfig(tenantId: string, projectId: string, apiKey: strin

export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const [settingsResult, navResult] = await Promise.allSettled([
getCachedSiteSettings(),
getCachedNavConfig(),
}: Readonly<{ children: React.ReactNode }>) {
// middleware.ts stamps x-tenant-domain on every request.
const headersList = await headers();
Expand Down Expand Up @@ -91,6 +119,8 @@ export default async function RootLayout({
buildCachedSiteSettings(tenant.tenantId, projectId, apiKey)(),
buildCachedNavConfig(tenant.tenantId, projectId, apiKey)(),
]);
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : null;
const navItems = navResult.status === "fulfilled" ? navResult.value : DEFAULT_NAV_ITEMS;

const navItems = navItemsOrNull ?? DEFAULT_NAV_ITEMS;

Expand Down Expand Up @@ -130,6 +160,13 @@ export default async function RootLayout({
İçeriğe geç
</a>
<ThemeListener />
<Providers>
<NavProvider initialItems={navItems}>
<LogoProvider initialLogoUrl={settings?.logoUrl ?? null}>
{children}
</LogoProvider>
</NavProvider>
</Providers>
{/*
TenantFirebaseProvider is a Client Component. It receives the tenant's
Firebase config as a plain serializable prop — no secrets cross the
Expand Down
2 changes: 2 additions & 0 deletions components/admin/AdminShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
UserCircle2,
CalendarDays,
ChartNoAxesColumn,
ImageIcon,
} from "lucide-react";

const EXTRA_PAGE_LABELS: Record<string, string> = {
Expand All @@ -41,6 +42,7 @@ const navItems = [
{ label: "Raporlar", href: "/admin/reports", icon: BarChart2 },
{ label: "IBAN Bilgileri", href: "/admin/iban", icon: CreditCard },
{ label: "Tema & Renkler", href: "/admin/theme", icon: Palette },
{ label: "Site Logosu", href: "/admin/branding", icon: ImageIcon },
];

export default function AdminShell({ children }: { children: React.ReactNode }) {
Expand Down
11 changes: 10 additions & 1 deletion components/admin/pageBuilder/BlockEditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState } from "react";
import {
ChevronUp, ChevronDown, ChevronRight, ChevronDown as Expand,
Trash2, Plus, GripVertical,
Mountain, AlignLeft, CalendarDays, FileText, Users, HelpCircle, Megaphone, BarChart2,
Mountain, AlignLeft, CalendarDays, FileText, Users, HelpCircle, Megaphone, BarChart2, ImageIcon,
} from "lucide-react";
import type { PageSection, BlockType } from "@/types/pageBuilder";
import { BLOCK_TYPE_LABELS } from "@/types/pageBuilder";
Expand All @@ -17,6 +17,7 @@ import { ReportListBlockEditor } from "./ReportListBlockEditor";
import { TeamGridBlockEditor } from "./TeamGridBlockEditor";
import { FaqBlockEditor } from "./FaqBlockEditor";
import { StatsBarBlockEditor } from "./StatsBarBlockEditor";
import { ImageBlockEditor } from "./ImageBlockEditor";

// ── Helpers ───────────────────────────────────────────────────────────────────

Expand All @@ -30,6 +31,8 @@ function defaultDataForType(type: BlockType): Record<string, unknown> {
return { title: "", subtitle: "", imageUrl: "", ctaLabel: "", ctaHref: "", overlayDark: false };
case "rich-text":
return { markdown: "" };
case "image":
return { imageUrl: "", altText: "", caption: "", width: "container", alignment: "center" };
case "cta-banner":
return { heading: "", body: "", buttonLabel: "", buttonHref: "", variant: "teal" };
case "event-grid":
Expand All @@ -48,6 +51,7 @@ function defaultDataForType(type: BlockType): Record<string, unknown> {
const TYPE_ICONS: Record<BlockType, React.ComponentType<{ className?: string }>> = {
"hero": Mountain,
"rich-text": AlignLeft,
"image": ImageIcon,
"event-grid": CalendarDays,
"report-list": FileText,
"team-grid": Users,
Expand All @@ -59,6 +63,7 @@ const TYPE_ICONS: Record<BlockType, React.ComponentType<{ className?: string }>>
const TYPE_COLORS: Record<BlockType, string> = {
"hero": "bg-violet-100 text-violet-700",
"rich-text": "bg-blue-100 text-blue-700",
"image": "bg-rose-100 text-rose-700",
"event-grid": "bg-amber-100 text-amber-700",
"report-list": "bg-orange-100 text-orange-700",
"team-grid": "bg-pink-100 text-pink-700",
Expand All @@ -74,6 +79,8 @@ function previewText(section: PageSection): string {
return String(d.title || "—");
case "rich-text":
return String(d.markdown || "—").replace(/[#*_`]/g, "").slice(0, 60) + (String(d.markdown || "").length > 60 ? "…" : "");
case "image":
return d.imageUrl ? (d.caption ? String(d.caption) : "Görsel yüklendi") : "Görsel seçilmedi";
case "cta-banner":
return String(d.heading || "—");
case "event-grid":
Expand Down Expand Up @@ -109,6 +116,8 @@ function BlockSpecificEditor({
return <HeroBlockEditor data={section.data} onChange={onChange} />;
case "rich-text":
return <RichTextBlockEditor data={section.data} onChange={onChange} />;
case "image":
return <ImageBlockEditor data={section.data} onChange={onChange} />;
case "cta-banner":
return <CtaBannerBlockEditor data={section.data} onChange={onChange} />;
case "event-grid":
Expand Down
8 changes: 5 additions & 3 deletions components/admin/pageBuilder/BlockTypeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import { X, Mountain, AlignLeft, CalendarDays, FileText, Users, HelpCircle, Megaphone, BarChart2 } from "lucide-react";
import { X, Mountain, AlignLeft, CalendarDays, FileText, Users, HelpCircle, Megaphone, BarChart2, ImageIcon } from "lucide-react";
import type { BlockType } from "@/types/pageBuilder";
import { BLOCK_TYPE_LABELS } from "@/types/pageBuilder";

const BLOCK_DESCRIPTIONS: Record<BlockType, string> = {
"hero": "Büyük arka plan görseli, başlık ve eylem butonu",
"rich-text": "Markdown formatında zengin metin içeriği",
"image": "Tek görsel; genişlik ve hizalama seçeneğiyle",
"event-grid": "Etkinlik kartları ızgarası",
"report-list": "PDF ve araştırma belgesi listesi",
"team-grid": "Ekip üyeleri kartları",
Expand All @@ -18,6 +19,7 @@ const BLOCK_DESCRIPTIONS: Record<BlockType, string> = {
const BLOCK_ICONS: Record<BlockType, React.ComponentType<{ className?: string }>> = {
"hero": Mountain,
"rich-text": AlignLeft,
"image": ImageIcon,
"event-grid": CalendarDays,
"report-list": FileText,
"team-grid": Users,
Expand All @@ -27,8 +29,8 @@ const BLOCK_ICONS: Record<BlockType, React.ComponentType<{ className?: string }>
};

const BLOCK_ORDER: BlockType[] = [
"hero", "rich-text", "cta-banner", "event-grid",
"report-list", "team-grid", "faq-accordion", "stats-bar",
"hero", "rich-text", "image", "cta-banner",
"event-grid", "report-list", "team-grid", "faq-accordion", "stats-bar",
];

interface BlockTypeSelectorProps {
Expand Down
96 changes: 96 additions & 0 deletions components/admin/pageBuilder/ImageBlockEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"use client";

import type { ImageBlockData } from "@/types/pageBuilder";
import { ImageUploadField } from "@/components/admin/shared/ImageUploadField";

const inputCls =
"w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-teal-500 focus:outline-none focus:ring-1 focus:ring-teal-500";

const selectCls =
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-teal-500 focus:outline-none focus:ring-1 focus:ring-teal-500";

interface ImageBlockEditorProps {
data: Record<string, unknown>;
onChange: (data: Record<string, unknown>) => void;
}

export function ImageBlockEditor({ data, onChange }: ImageBlockEditorProps) {
const d: ImageBlockData = {
imageUrl: "",
altText: "",
caption: "",
width: "container",
alignment: "center",
...(data as Partial<ImageBlockData>),
};

function set<K extends keyof ImageBlockData>(key: K, value: ImageBlockData[K]) {
onChange({ ...d, [key]: value });
}

return (
<div className="space-y-4">
{/* Image upload */}
<ImageUploadField
label="Görsel"
value={d.imageUrl}
onChange={(url) => set("imageUrl", url)}
hint="Firebase Storage'a yüklenir. PNG, JPG, WebP desteklenir."
/>

{/* Alt text */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">
Alt Metin <span className="text-gray-400 font-normal">(erişilebilirlik için)</span>
</label>
<input
className={inputCls}
placeholder="Görseli kısaca açıklayın"
value={d.altText}
onChange={(e) => set("altText", e.target.value)}
/>
</div>

{/* Caption */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">
Açıklama Yazısı <span className="text-gray-400 font-normal">(isteğe bağlı)</span>
</label>
<input
className={inputCls}
placeholder="Görselin altında görünecek kısa açıklama"
value={d.caption ?? ""}
onChange={(e) => set("caption", e.target.value)}
/>
</div>

{/* Width + alignment */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">Genişlik</label>
<select
className={selectCls}
value={d.width}
onChange={(e) => set("width", e.target.value as ImageBlockData["width"])}
>
<option value="narrow">Dar (640 px)</option>
<option value="container">Normal (sayfa genişliği)</option>
<option value="full">Tam ekran genişliği</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">Hizalama</label>
<select
className={selectCls}
value={d.alignment}
onChange={(e) => set("alignment", e.target.value as ImageBlockData["alignment"])}
>
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sağ</option>
</select>
</div>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions components/blocks/BlockRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ReportListBlock } from "./ReportListBlock";
import { TeamGridBlock } from "./TeamGridBlock";
import { FaqAccordionBlock } from "./FaqAccordionBlock";
import { StatsBarBlock } from "./StatsBarBlock";
import { ImageBlock } from "./ImageBlock";

function renderBlock(section: PageSection) {
switch (section.type) {
Expand All @@ -26,6 +27,8 @@ function renderBlock(section: PageSection) {
return <FaqAccordionBlock key={section.id} data={section.data} />;
case "stats-bar":
return <StatsBarBlock key={section.id} data={section.data} />;
case "image":
return <ImageBlock key={section.id} data={section.data} />;
default:
return null;
}
Expand Down
Loading
Loading