ADDED - Admin SEO page, robots/sitemap hardening and media/maintenance security fixes
SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
LogOut,
|
||||
Palette,
|
||||
PlusSquare,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
SwatchBook,
|
||||
Tags,
|
||||
@@ -40,6 +41,7 @@ type AdminDashboardCopy = {
|
||||
siteSettings: string;
|
||||
brandSettings?: string;
|
||||
localizationSettings?: string;
|
||||
seoSettings?: string;
|
||||
marquee?: string;
|
||||
smtp?: string;
|
||||
logout: string;
|
||||
@@ -50,7 +52,7 @@ type AdminDashboardShellProps = {
|
||||
copy: AdminDashboardCopy;
|
||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
|
||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
||||
siteSettingsChild?: "brand" | "localization";
|
||||
siteSettingsChild?: "brand" | "localization" | "seo";
|
||||
flash?: FlashMessages;
|
||||
logoutAction: () => Promise<void>;
|
||||
headerTitle: string;
|
||||
@@ -100,6 +102,8 @@ export async function AdminDashboardShell({
|
||||
: active === "site-settings"
|
||||
? siteSettingsChild === "localization"
|
||||
? Languages
|
||||
: siteSettingsChild === "seo"
|
||||
? Search
|
||||
: siteSettingsChild === "brand"
|
||||
? Palette
|
||||
: Globe2
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { ExternalLink, FileCode2, Map as MapIcon, Bot, CheckCircle2, AlertTriangle, XCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { SeoCheck } from "@/lib/seo-report";
|
||||
import { summarizeSeoChecklist } from "@/lib/seo-report";
|
||||
import type { SeoSettings } from "@/lib/seo-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SeoSettingsFormProps = {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
settings: SeoSettings;
|
||||
checks: SeoCheck[];
|
||||
links: {
|
||||
sitemap: string;
|
||||
robots: string;
|
||||
manifest: string;
|
||||
};
|
||||
sitemapEntryCount: number;
|
||||
};
|
||||
|
||||
const localeKeywordFields = [
|
||||
{ key: "de", name: "keywordsDe", label: "Keywords (Deutsch)" },
|
||||
{ key: "en", name: "keywordsEn", label: "Keywords (English)" },
|
||||
{ key: "ar", name: "keywordsAr", label: "Keywords (Arabic)" },
|
||||
] as const;
|
||||
|
||||
function StatusIcon({ status }: { status: SeoCheck["status"] }) {
|
||||
if (status === "ok") {
|
||||
return <CheckCircle2 className="h-4 w-4 text-status-success" aria-label="OK" />;
|
||||
}
|
||||
|
||||
if (status === "warn") {
|
||||
return <AlertTriangle className="h-4 w-4 text-status-warning" aria-label="Hinweis" />;
|
||||
}
|
||||
|
||||
return <XCircle className="h-4 w-4 text-destructive" aria-label="Fehler" />;
|
||||
}
|
||||
|
||||
function FileLink({
|
||||
href,
|
||||
label,
|
||||
description,
|
||||
icon: Icon,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof MapIcon;
|
||||
}) {
|
||||
return (
|
||||
<AppCard level={2} padding="sm" contentClassName="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-nested border border-border bg-muted/50 text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={href} target="_blank" rel="noreferrer">
|
||||
Oeffnen
|
||||
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeoSettingsForm({ action, settings, checks, links, sitemapEntryCount }: SeoSettingsFormProps) {
|
||||
const summary = summarizeSeoChecklist(checks);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatsCard title="Bereit" value={String(summary.ok)} description="Checks bestanden" />
|
||||
<StatsCard title="Hinweise" value={String(summary.warn)} description="Empfohlen zu pruefen" />
|
||||
<StatsCard title="Fehler" value={String(summary.error)} description="Blockiert Sichtbarkeit" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<form id="seo-settings-form" action={action} className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sichtbarkeit</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Steuert robots.txt, die Sitemap und das robots Meta Tag aller oeffentlichen Seiten.
|
||||
</p>
|
||||
</div>
|
||||
<AppCard level={2} padding="sm" contentClassName="space-y-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowIndexing"
|
||||
value="on"
|
||||
defaultChecked={settings.allowIndexing}
|
||||
className="mt-1 h-4 w-4 rounded border-border accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-foreground">Indexierung erlauben</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Aus = noindex auf allen Seiten, robots.txt sperrt alles, Sitemap wird leer. Der Wartungsmodus
|
||||
sperrt zusaetzlich automatisch.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Verifizierung & Social</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Codes aus Google Search Console / Bing Webmaster und das X-Handle fuer Twitter Cards.
|
||||
</p>
|
||||
</div>
|
||||
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="googleSiteVerification">Google Verification</Label>
|
||||
<Input
|
||||
id="googleSiteVerification"
|
||||
name="googleSiteVerification"
|
||||
defaultValue={settings.googleSiteVerification}
|
||||
placeholder="google-site-verification Wert"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bingSiteVerification">Bing Verification</Label>
|
||||
<Input
|
||||
id="bingSiteVerification"
|
||||
name="bingSiteVerification"
|
||||
defaultValue={settings.bingSiteVerification}
|
||||
placeholder="msvalidate.01 Wert"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="twitterHandle">X / Twitter Handle</Label>
|
||||
<Input
|
||||
id="twitterHandle"
|
||||
name="twitterHandle"
|
||||
defaultValue={settings.twitterHandle}
|
||||
placeholder="@handle"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Strukturierte Daten</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
JSON-LD fuer Google: Wer steht hinter der Seite? Gilt fuer alle Seiten und jede Projekt-Ansicht.
|
||||
</p>
|
||||
</div>
|
||||
<AppCard level={2} padding="sm" contentClassName="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="structuredDataType">Typ</Label>
|
||||
<select
|
||||
id="structuredDataType"
|
||||
name="structuredDataType"
|
||||
defaultValue={settings.structuredDataType}
|
||||
className="flex h-10 w-full rounded-nested border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="Person">Person (Freelancer / Portfolio)</option>
|
||||
<option value="Organization">Organization (Studio / Firma)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="structuredDataName">Name</Label>
|
||||
<Input
|
||||
id="structuredDataName"
|
||||
name="structuredDataName"
|
||||
defaultValue={settings.structuredDataName}
|
||||
placeholder="Leer = Site Name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="structuredDataJobTitle">Job Title / Slogan</Label>
|
||||
<Input
|
||||
id="structuredDataJobTitle"
|
||||
name="structuredDataJobTitle"
|
||||
defaultValue={settings.structuredDataJobTitle}
|
||||
placeholder="z.B. Brand & Motion Designer"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="sameAs">Social Profile (eine https-URL pro Zeile)</Label>
|
||||
<Textarea
|
||||
id="sameAs"
|
||||
name="sameAs"
|
||||
rows={4}
|
||||
defaultValue={settings.sameAs.join("\n")}
|
||||
placeholder={"https://www.behance.net/...\nhttps://www.linkedin.com/in/..."}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Keywords</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kommagetrennt, pro Sprache. Geringe Gewichtung bei Google, aber nuetzlich fuer Bing und Struktur.
|
||||
</p>
|
||||
</div>
|
||||
<AppCard level={2} padding="sm" contentClassName="grid gap-4">
|
||||
{localeKeywordFields.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={field.name}>{field.label}</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
defaultValue={settings.locales[field.key].keywords}
|
||||
dir={field.key === "ar" ? "rtl" : "ltr"}
|
||||
placeholder="branding, motion design, berlin"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">Save SEO Settings</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Dateien</h2>
|
||||
<p className="text-sm text-muted-foreground">Werden live aus den Einstellungen generiert.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FileLink
|
||||
href={links.sitemap}
|
||||
label="sitemap.xml"
|
||||
description={`${sitemapEntryCount} URLs, hreflang fuer DE/EN/AR`}
|
||||
icon={MapIcon}
|
||||
/>
|
||||
<FileLink href={links.robots} label="robots.txt" description="Crawler-Regeln + Sitemap-Verweis" icon={Bot} />
|
||||
<FileLink href={links.manifest} label="manifest.webmanifest" description="PWA / Icons" icon={FileCode2} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Checkliste</h2>
|
||||
<p className="text-sm text-muted-foreground">Status der wichtigsten SEO-Bausteine.</p>
|
||||
</div>
|
||||
<AppCard level={2} padding="sm" contentClassName="divide-y divide-border/60">
|
||||
{checks.map((check) => (
|
||||
<div key={check.id} className={cn("flex items-start gap-3 py-2.5 first:pt-0 last:pb-0")}>
|
||||
<span className="mt-0.5 shrink-0">
|
||||
<StatusIcon status={check.status} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{check.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{check.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AppCard>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user