@morphika/andami 0.6.0 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(site)/[slug]/page.tsx +7 -0
- package/app/(site)/layout.tsx +4 -15
- package/app/(site)/work/[slug]/page.tsx +10 -0
- package/app/admin/settings/page.tsx +136 -132
- package/app/api/admin/settings/route.ts +69 -0
- package/app/llms.txt/route.ts +146 -0
- package/app/robots.ts +73 -54
- package/app/sitemap.ts +25 -6
- package/components/admin/MetadataEditor.tsx +331 -32
- package/components/seo/BreadcrumbJsonLd.tsx +49 -0
- package/components/seo/JsonLd.tsx +50 -0
- package/components/seo/ProjectJsonLd.tsx +44 -0
- package/components/seo/SiteSeoHead.tsx +66 -0
- package/lib/sanity/queries.ts +24 -7
- package/lib/sanity/types.ts +28 -0
- package/lib/seo/jsonld.ts +219 -0
- package/lib/seo/site-settings.ts +37 -0
- package/lib/version.ts +1 -1
- package/package.json +2 -1
- package/sanity/schemas/siteSettings.ts +102 -0
- package/site/llms-txt.ts +23 -0
package/app/robots.ts
CHANGED
|
@@ -1,54 +1,73 @@
|
|
|
1
|
-
import type { MetadataRoute } from "next";
|
|
2
|
-
import { getSiteConfig } from "../lib/config";
|
|
3
|
-
|
|
4
|
-
const cfg = getSiteConfig();
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* robots.txt — Controls crawler access and rate.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { getSiteConfig } from "../lib/config";
|
|
3
|
+
|
|
4
|
+
const cfg = getSiteConfig();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* robots.txt — Controls crawler access and rate.
|
|
8
|
+
*
|
|
9
|
+
* Two-tier AI bot strategy:
|
|
10
|
+
*
|
|
11
|
+
* 1. TRAINING bots (block) — crawl pages to train LLMs on the content.
|
|
12
|
+
* Blocking them protects IP from being absorbed into model weights.
|
|
13
|
+
* Examples: GPTBot, CCBot, Google-Extended, ClaudeBot, anthropic-ai.
|
|
14
|
+
*
|
|
15
|
+
* 2. CITATION / ON-DEMAND bots (allow) — fetch URLs in real time when a
|
|
16
|
+
* user asks an AI assistant a question. Blocking them means we lose
|
|
17
|
+
* citation opportunities in ChatGPT, Perplexity, Claude responses.
|
|
18
|
+
* Examples: ChatGPT-User, Perplexity-User, Claude-User, OAI-SearchBot.
|
|
19
|
+
*
|
|
20
|
+
* Distinction matters because we want LLMs to *cite* us, not *learn from*
|
|
21
|
+
* us. The bots have different User-Agent strings; granular control via
|
|
22
|
+
* per-UA rules.
|
|
23
|
+
*
|
|
24
|
+
* Crawl-delay (seconds between requests) is honoured by Bing, Yandex,
|
|
25
|
+
* Baidu and most well-behaved bots. Googlebot ignores it but respects the
|
|
26
|
+
* rate configured in Search Console.
|
|
27
|
+
*
|
|
28
|
+
* Last reviewed: 2026-05-15.
|
|
29
|
+
*/
|
|
30
|
+
export default function robots(): MetadataRoute.Robots {
|
|
31
|
+
return {
|
|
32
|
+
rules: [
|
|
33
|
+
// ─────────────────────────────────────────────
|
|
34
|
+
// TRAINING BOTS — explicitly blocked
|
|
35
|
+
// ─────────────────────────────────────────────
|
|
36
|
+
{ userAgent: "GPTBot", disallow: ["/"] }, // OpenAI training crawler
|
|
37
|
+
{ userAgent: "CCBot", disallow: ["/"] }, // Common Crawl (feeds most LLMs)
|
|
38
|
+
{ userAgent: "Google-Extended", disallow: ["/"] }, // Google's AI training (separate from Googlebot)
|
|
39
|
+
{ userAgent: "anthropic-ai", disallow: ["/"] }, // Older Anthropic training UA
|
|
40
|
+
{ userAgent: "ClaudeBot", disallow: ["/"] }, // Anthropic Claude training
|
|
41
|
+
{ userAgent: "FacebookBot", disallow: ["/"] }, // Meta/LLaMA training
|
|
42
|
+
{ userAgent: "Applebot-Extended", disallow: ["/"] }, // Apple Intelligence training (separate from Applebot which indexes for Siri/Spotlight — that one is allowed implicitly)
|
|
43
|
+
{ userAgent: "Bytespider", disallow: ["/"] }, // ByteDance / TikTok training crawler
|
|
44
|
+
{ userAgent: "PetalBot", disallow: ["/"] }, // Huawei / Petal Search aggressive crawler
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────
|
|
47
|
+
// CITATION / ON-DEMAND BOTS — explicitly allowed
|
|
48
|
+
// (they're allowed by default anyway, but we list
|
|
49
|
+
// them to make the policy explicit and to override
|
|
50
|
+
// any future changes to the catch-all rule.)
|
|
51
|
+
// ─────────────────────────────────────────────
|
|
52
|
+
{ userAgent: "ChatGPT-User", allow: "/" }, // ChatGPT user-triggered fetches
|
|
53
|
+
{ userAgent: "OAI-SearchBot", allow: "/" }, // OpenAI search index
|
|
54
|
+
{ userAgent: "PerplexityBot", allow: "/" }, // Perplexity index
|
|
55
|
+
{ userAgent: "Perplexity-User", allow: "/" }, // Perplexity user-triggered fetches
|
|
56
|
+
{ userAgent: "Claude-User", allow: "/" }, // Claude user-triggered fetches
|
|
57
|
+
{ userAgent: "Claude-Web", allow: "/" }, // Claude web search
|
|
58
|
+
{ userAgent: "Google-CloudVertexBot", allow: "/" }, // Vertex AI on-demand fetch
|
|
59
|
+
{ userAgent: "YouBot", allow: "/" }, // You.com AI search
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────
|
|
62
|
+
// DEFAULT — Googlebot, Bingbot, and everyone else
|
|
63
|
+
// ─────────────────────────────────────────────
|
|
64
|
+
{
|
|
65
|
+
userAgent: "*",
|
|
66
|
+
allow: "/",
|
|
67
|
+
disallow: ["/admin/", "/studio/", "/api/admin/", "/api/"],
|
|
68
|
+
crawlDelay: 10,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
sitemap: `${cfg.domain}/sitemap.xml`,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/app/sitemap.ts
CHANGED
|
@@ -2,28 +2,43 @@ import type { MetadataRoute } from "next";
|
|
|
2
2
|
import { client } from "../lib/sanity/client";
|
|
3
3
|
import { allPagesForSitemapQuery, allProjectsForSitemapQuery } from "../lib/sanity/queries";
|
|
4
4
|
import { getSiteConfig } from "../lib/config";
|
|
5
|
+
import { assetUrl } from "../lib/assets";
|
|
6
|
+
import { toAbsoluteUrl } from "../lib/seo/site-settings";
|
|
5
7
|
|
|
6
8
|
const cfg = getSiteConfig();
|
|
7
9
|
|
|
8
|
-
interface
|
|
10
|
+
interface PageSitemapEntry {
|
|
9
11
|
slug: string;
|
|
10
12
|
updatedAt: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
interface ProjectSitemapEntry extends PageSitemapEntry {
|
|
16
|
+
thumbnail_path?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
published_at?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* sitemap.xml — Dynamic generation from Sanity content.
|
|
15
24
|
*
|
|
16
25
|
* Each entry's <lastmod> uses Sanity's _updatedAt field (not the build time),
|
|
17
26
|
* which is the correct SEO signal: Google deprioritizes sitemaps with all
|
|
18
|
-
* identical timestamps.
|
|
19
|
-
*
|
|
27
|
+
* identical timestamps.
|
|
28
|
+
*
|
|
29
|
+
* Project entries include their thumbnail in the `images` array — Next.js
|
|
30
|
+
* emits this as <image:image> nodes per the Google Image Sitemap protocol,
|
|
31
|
+
* allowing Google Images to index project thumbnails alongside the page URL.
|
|
32
|
+
*
|
|
33
|
+
* Pages/projects with metadata.noindex == true are excluded at the GROQ
|
|
34
|
+
* level (see lib/sanity/queries.ts).
|
|
20
35
|
*/
|
|
21
36
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
22
37
|
const baseUrl = cfg.domain;
|
|
23
38
|
|
|
24
39
|
const [pages, projects] = await Promise.all([
|
|
25
|
-
client.fetch<
|
|
26
|
-
client.fetch<
|
|
40
|
+
client.fetch<PageSitemapEntry[]>(allPagesForSitemapQuery).catch(() => [] as PageSitemapEntry[]),
|
|
41
|
+
client.fetch<ProjectSitemapEntry[]>(allProjectsForSitemapQuery).catch(() => [] as ProjectSitemapEntry[]),
|
|
27
42
|
]);
|
|
28
43
|
|
|
29
44
|
// Homepage — lastModified derived from the most recent page or project update
|
|
@@ -53,12 +68,16 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
53
68
|
});
|
|
54
69
|
}
|
|
55
70
|
|
|
56
|
-
for (const { slug, updatedAt } of projects) {
|
|
71
|
+
for (const { slug, updatedAt, thumbnail_path } of projects) {
|
|
72
|
+
const thumbnailUrl = thumbnail_path
|
|
73
|
+
? toAbsoluteUrl(assetUrl(thumbnail_path), baseUrl)
|
|
74
|
+
: undefined;
|
|
57
75
|
routes.push({
|
|
58
76
|
url: `${baseUrl}/work/${slug}`,
|
|
59
77
|
lastModified: updatedAt ? new Date(updatedAt) : new Date(),
|
|
60
78
|
changeFrequency: "monthly",
|
|
61
79
|
priority: 0.7,
|
|
80
|
+
...(thumbnailUrl && { images: [thumbnailUrl] }),
|
|
62
81
|
});
|
|
63
82
|
}
|
|
64
83
|
|
|
@@ -8,12 +8,28 @@ import SERPPreview from "./SERPPreview";
|
|
|
8
8
|
const TITLE_LIMIT = 60;
|
|
9
9
|
const DESCRIPTION_LIMIT = 160;
|
|
10
10
|
|
|
11
|
-
interface
|
|
11
|
+
export interface SocialLinkInput {
|
|
12
|
+
_key?: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
url: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MetadataData {
|
|
12
18
|
default_title: string;
|
|
13
19
|
default_description: string;
|
|
14
20
|
default_og_image: string;
|
|
15
21
|
favicon_path: string;
|
|
16
22
|
analytics_id: string;
|
|
23
|
+
org_logo: string;
|
|
24
|
+
default_author: string;
|
|
25
|
+
social_links: SocialLinkInput[];
|
|
26
|
+
founding_year: number | undefined;
|
|
27
|
+
address_locality: string;
|
|
28
|
+
address_country: string;
|
|
29
|
+
contact_email: string;
|
|
30
|
+
keywords: string[];
|
|
31
|
+
verification_google: string;
|
|
32
|
+
verification_bing: string;
|
|
17
33
|
}
|
|
18
34
|
|
|
19
35
|
interface MetadataEditorProps {
|
|
@@ -22,18 +38,38 @@ interface MetadataEditorProps {
|
|
|
22
38
|
saving: boolean;
|
|
23
39
|
}
|
|
24
40
|
|
|
41
|
+
const INPUT_CLASS =
|
|
42
|
+
"w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 focus:border-[#3580f9] focus:outline-none";
|
|
43
|
+
|
|
44
|
+
const SECTION_TITLE_CLASS =
|
|
45
|
+
"text-sm font-semibold text-neutral-800 pt-6 border-t border-neutral-200";
|
|
46
|
+
|
|
25
47
|
export default function MetadataEditor({
|
|
26
48
|
initialData,
|
|
27
49
|
onSave,
|
|
28
50
|
saving,
|
|
29
51
|
}: MetadataEditorProps) {
|
|
30
52
|
const [title, setTitle] = useState(initialData.default_title);
|
|
31
|
-
const [description, setDescription] = useState(
|
|
32
|
-
initialData.default_description
|
|
33
|
-
);
|
|
53
|
+
const [description, setDescription] = useState(initialData.default_description);
|
|
34
54
|
const [ogImage, setOgImage] = useState(initialData.default_og_image);
|
|
35
55
|
const [favicon, setFavicon] = useState(initialData.favicon_path);
|
|
36
56
|
const [analyticsId, setAnalyticsId] = useState(initialData.analytics_id);
|
|
57
|
+
const [orgLogo, setOrgLogo] = useState(initialData.org_logo);
|
|
58
|
+
const [defaultAuthor, setDefaultAuthor] = useState(initialData.default_author);
|
|
59
|
+
const [socialLinks, setSocialLinks] = useState<SocialLinkInput[]>(
|
|
60
|
+
initialData.social_links || []
|
|
61
|
+
);
|
|
62
|
+
const [foundingYear, setFoundingYear] = useState<number | undefined>(
|
|
63
|
+
initialData.founding_year
|
|
64
|
+
);
|
|
65
|
+
const [addressLocality, setAddressLocality] = useState(initialData.address_locality);
|
|
66
|
+
const [addressCountry, setAddressCountry] = useState(initialData.address_country);
|
|
67
|
+
const [contactEmail, setContactEmail] = useState(initialData.contact_email);
|
|
68
|
+
const [keywordsText, setKeywordsText] = useState(
|
|
69
|
+
(initialData.keywords || []).join(", ")
|
|
70
|
+
);
|
|
71
|
+
const [verGoogle, setVerGoogle] = useState(initialData.verification_google);
|
|
72
|
+
const [verBing, setVerBing] = useState(initialData.verification_bing);
|
|
37
73
|
|
|
38
74
|
useEffect(() => {
|
|
39
75
|
setTitle(initialData.default_title);
|
|
@@ -41,18 +77,57 @@ export default function MetadataEditor({
|
|
|
41
77
|
setOgImage(initialData.default_og_image);
|
|
42
78
|
setFavicon(initialData.favicon_path);
|
|
43
79
|
setAnalyticsId(initialData.analytics_id);
|
|
80
|
+
setOrgLogo(initialData.org_logo);
|
|
81
|
+
setDefaultAuthor(initialData.default_author);
|
|
82
|
+
setSocialLinks(initialData.social_links || []);
|
|
83
|
+
setFoundingYear(initialData.founding_year);
|
|
84
|
+
setAddressLocality(initialData.address_locality);
|
|
85
|
+
setAddressCountry(initialData.address_country);
|
|
86
|
+
setContactEmail(initialData.contact_email);
|
|
87
|
+
setKeywordsText((initialData.keywords || []).join(", "));
|
|
88
|
+
setVerGoogle(initialData.verification_google);
|
|
89
|
+
setVerBing(initialData.verification_bing);
|
|
44
90
|
}, [initialData]);
|
|
45
91
|
|
|
46
92
|
const handleSave = () => {
|
|
93
|
+
// Parse keywords from comma-separated input, trim + dedupe
|
|
94
|
+
const keywords = Array.from(
|
|
95
|
+
new Set(
|
|
96
|
+
keywordsText
|
|
97
|
+
.split(",")
|
|
98
|
+
.map((k) => k.trim())
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
)
|
|
101
|
+
);
|
|
47
102
|
onSave({
|
|
48
103
|
default_title: title,
|
|
49
104
|
default_description: description,
|
|
50
105
|
default_og_image: ogImage,
|
|
51
106
|
favicon_path: favicon,
|
|
52
107
|
analytics_id: analyticsId,
|
|
108
|
+
org_logo: orgLogo,
|
|
109
|
+
default_author: defaultAuthor,
|
|
110
|
+
social_links: socialLinks.filter((l) => l.url?.trim()),
|
|
111
|
+
founding_year: foundingYear,
|
|
112
|
+
address_locality: addressLocality,
|
|
113
|
+
address_country: addressCountry,
|
|
114
|
+
contact_email: contactEmail,
|
|
115
|
+
keywords,
|
|
116
|
+
verification_google: verGoogle,
|
|
117
|
+
verification_bing: verBing,
|
|
53
118
|
});
|
|
54
119
|
};
|
|
55
120
|
|
|
121
|
+
const updateSocialLink = (i: number, patch: Partial<SocialLinkInput>) => {
|
|
122
|
+
setSocialLinks((arr) =>
|
|
123
|
+
arr.map((l, idx) => (idx === i ? { ...l, ...patch } : l))
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
const removeSocialLink = (i: number) =>
|
|
127
|
+
setSocialLinks((arr) => arr.filter((_, idx) => idx !== i));
|
|
128
|
+
const addSocialLink = () =>
|
|
129
|
+
setSocialLinks((arr) => [...arr, { url: "", label: "" }]);
|
|
130
|
+
|
|
56
131
|
const titleOver = title.length > TITLE_LIMIT;
|
|
57
132
|
const descOver = description.length > DESCRIPTION_LIMIT;
|
|
58
133
|
|
|
@@ -69,7 +144,10 @@ export default function MetadataEditor({
|
|
|
69
144
|
Affects search results and social sharing.
|
|
70
145
|
</p>
|
|
71
146
|
|
|
72
|
-
{/*
|
|
147
|
+
{/* ============================================================
|
|
148
|
+
DEFAULTS
|
|
149
|
+
============================================================ */}
|
|
150
|
+
|
|
73
151
|
<div className="space-y-1">
|
|
74
152
|
<label className="text-xs font-medium text-neutral-500">
|
|
75
153
|
Default Page Title
|
|
@@ -79,22 +157,18 @@ export default function MetadataEditor({
|
|
|
79
157
|
value={title}
|
|
80
158
|
onChange={(e) => setTitle(e.target.value)}
|
|
81
159
|
placeholder={getSiteConfig().defaults.metaTitle}
|
|
82
|
-
className=
|
|
160
|
+
className={INPUT_CLASS}
|
|
83
161
|
/>
|
|
84
162
|
<div className="flex justify-between">
|
|
85
163
|
<p className="text-xs text-neutral-400">
|
|
86
|
-
Shown in browser tabs and search results when no page-specific title
|
|
87
|
-
is set.
|
|
164
|
+
Shown in browser tabs and search results when no page-specific title is set.
|
|
88
165
|
</p>
|
|
89
|
-
<span
|
|
90
|
-
className={`text-xs ${titleOver ? "text-red-400" : "text-neutral-400"}`}
|
|
91
|
-
>
|
|
166
|
+
<span className={`text-xs ${titleOver ? "text-red-400" : "text-neutral-400"}`}>
|
|
92
167
|
{title.length}/{TITLE_LIMIT}
|
|
93
168
|
</span>
|
|
94
169
|
</div>
|
|
95
170
|
</div>
|
|
96
171
|
|
|
97
|
-
{/* Description */}
|
|
98
172
|
<div className="space-y-1">
|
|
99
173
|
<label className="text-xs font-medium text-neutral-500">
|
|
100
174
|
Default Description
|
|
@@ -104,24 +178,20 @@ export default function MetadataEditor({
|
|
|
104
178
|
onChange={(e) => setDescription(e.target.value)}
|
|
105
179
|
placeholder="Motion graphics studio based in Barcelona..."
|
|
106
180
|
rows={3}
|
|
107
|
-
className=
|
|
181
|
+
className={`${INPUT_CLASS} resize-none`}
|
|
108
182
|
/>
|
|
109
183
|
<div className="flex justify-between">
|
|
110
184
|
<p className="text-xs text-neutral-400">
|
|
111
185
|
Used in search results and social media previews.
|
|
112
186
|
</p>
|
|
113
|
-
<span
|
|
114
|
-
className={`text-xs ${descOver ? "text-red-400" : "text-neutral-400"}`}
|
|
115
|
-
>
|
|
187
|
+
<span className={`text-xs ${descOver ? "text-red-400" : "text-neutral-400"}`}>
|
|
116
188
|
{description.length}/{DESCRIPTION_LIMIT}
|
|
117
189
|
</span>
|
|
118
190
|
</div>
|
|
119
191
|
</div>
|
|
120
192
|
|
|
121
|
-
{/* SERP Preview */}
|
|
122
193
|
<SERPPreview title={title} description={description} />
|
|
123
194
|
|
|
124
|
-
{/* OG Image */}
|
|
125
195
|
<div className="space-y-1">
|
|
126
196
|
<label className="text-xs font-medium text-neutral-500">
|
|
127
197
|
Default OG Image
|
|
@@ -133,16 +203,12 @@ export default function MetadataEditor({
|
|
|
133
203
|
filterType="image"
|
|
134
204
|
/>
|
|
135
205
|
<p className="text-xs text-neutral-400">
|
|
136
|
-
Default image used when sharing on social media (Facebook, Twitter,
|
|
137
|
-
LinkedIn). Recommended size: 1200×630px. Pages can override per-page.
|
|
206
|
+
Default image used when sharing on social media (Facebook, Twitter, LinkedIn). Recommended size: 1200×630px. Pages can override per-page.
|
|
138
207
|
</p>
|
|
139
208
|
</div>
|
|
140
209
|
|
|
141
|
-
{/* Favicon */}
|
|
142
210
|
<div className="space-y-1">
|
|
143
|
-
<label className="text-xs font-medium text-neutral-500">
|
|
144
|
-
Favicon
|
|
145
|
-
</label>
|
|
211
|
+
<label className="text-xs font-medium text-neutral-500">Favicon</label>
|
|
146
212
|
<AssetPathInput
|
|
147
213
|
value={favicon}
|
|
148
214
|
onChange={setFavicon}
|
|
@@ -150,12 +216,10 @@ export default function MetadataEditor({
|
|
|
150
216
|
filterType="image"
|
|
151
217
|
/>
|
|
152
218
|
<p className="text-xs text-neutral-400">
|
|
153
|
-
Icon shown in browser tabs and bookmarks. Square images recommended
|
|
154
|
-
(16×16 .ico or 32×32+ .png/.svg).
|
|
219
|
+
Icon shown in browser tabs and bookmarks. Square images recommended (16×16 .ico or 32×32+ .png/.svg).
|
|
155
220
|
</p>
|
|
156
221
|
</div>
|
|
157
222
|
|
|
158
|
-
{/* Analytics ID */}
|
|
159
223
|
<div className="space-y-1">
|
|
160
224
|
<label className="text-xs font-medium text-neutral-500">
|
|
161
225
|
Analytics ID (optional)
|
|
@@ -165,16 +229,251 @@ export default function MetadataEditor({
|
|
|
165
229
|
value={analyticsId}
|
|
166
230
|
onChange={(e) => setAnalyticsId(e.target.value)}
|
|
167
231
|
placeholder="G-XXXXXXXXXX or plausible domain"
|
|
168
|
-
className=
|
|
232
|
+
className={INPUT_CLASS}
|
|
233
|
+
/>
|
|
234
|
+
<p className="text-xs text-neutral-400">
|
|
235
|
+
Google Analytics measurement ID or Plausible domain. Leave empty to disable analytics.
|
|
236
|
+
</p>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* ============================================================
|
|
240
|
+
SOCIAL & BRANDING
|
|
241
|
+
============================================================ */}
|
|
242
|
+
|
|
243
|
+
<h3 className={SECTION_TITLE_CLASS}>Social & Branding</h3>
|
|
244
|
+
<p className="text-xs text-neutral-400">
|
|
245
|
+
Used by structured data (schema.org) so Google can show your brand in
|
|
246
|
+
Knowledge Panels and link your social profiles.
|
|
247
|
+
</p>
|
|
248
|
+
|
|
249
|
+
<div className="space-y-1">
|
|
250
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
251
|
+
Organization Logo
|
|
252
|
+
</label>
|
|
253
|
+
<AssetPathInput
|
|
254
|
+
value={orgLogo}
|
|
255
|
+
onChange={setOrgLogo}
|
|
256
|
+
placeholder="meta/logo.png"
|
|
257
|
+
filterType="image"
|
|
258
|
+
/>
|
|
259
|
+
<p className="text-xs text-neutral-400">
|
|
260
|
+
Logo for Google's Knowledge Panel and rich results. Square (min 112×112, ideal 500×500).
|
|
261
|
+
</p>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div className="space-y-1">
|
|
265
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
266
|
+
Default Author
|
|
267
|
+
</label>
|
|
268
|
+
<input
|
|
269
|
+
type="text"
|
|
270
|
+
value={defaultAuthor}
|
|
271
|
+
onChange={(e) => setDefaultAuthor(e.target.value)}
|
|
272
|
+
placeholder={getSiteConfig().name}
|
|
273
|
+
className={INPUT_CLASS}
|
|
274
|
+
/>
|
|
275
|
+
<p className="text-xs text-neutral-400">
|
|
276
|
+
Author name used on project pages when not overridden. Typically your studio name.
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div className="space-y-2">
|
|
281
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
282
|
+
Social Profiles
|
|
283
|
+
</label>
|
|
284
|
+
<p className="text-xs text-neutral-400">
|
|
285
|
+
Profile URLs (Instagram, LinkedIn, Behance, Vimeo, etc.). Emitted as schema.org sameAs[] — helps Google link your organization to its social presence.
|
|
286
|
+
</p>
|
|
287
|
+
<div className="space-y-2">
|
|
288
|
+
{socialLinks.map((link, i) => (
|
|
289
|
+
<div key={link._key || i} className="flex gap-1.5 items-start">
|
|
290
|
+
<input
|
|
291
|
+
type="text"
|
|
292
|
+
value={link.label || ""}
|
|
293
|
+
onChange={(e) => updateSocialLink(i, { label: e.target.value })}
|
|
294
|
+
placeholder="Label (e.g. Instagram)"
|
|
295
|
+
className={`${INPUT_CLASS} flex-[1]`}
|
|
296
|
+
/>
|
|
297
|
+
<input
|
|
298
|
+
type="url"
|
|
299
|
+
value={link.url || ""}
|
|
300
|
+
onChange={(e) => updateSocialLink(i, { url: e.target.value })}
|
|
301
|
+
placeholder="https://instagram.com/yourhandle"
|
|
302
|
+
className={`${INPUT_CLASS} flex-[2]`}
|
|
303
|
+
/>
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
onClick={() => removeSocialLink(i)}
|
|
307
|
+
className="shrink-0 rounded-lg px-2.5 py-2.5 text-xs text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-colors"
|
|
308
|
+
aria-label="Remove social link"
|
|
309
|
+
>
|
|
310
|
+
✕
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
<button
|
|
316
|
+
type="button"
|
|
317
|
+
onClick={addSocialLink}
|
|
318
|
+
className="text-xs text-[#3580f9] hover:underline"
|
|
319
|
+
>
|
|
320
|
+
+ Add Profile
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{/* ============================================================
|
|
325
|
+
ORGANIZATION INFO (citation-friendly facts for LLMs + JSON-LD)
|
|
326
|
+
============================================================ */}
|
|
327
|
+
|
|
328
|
+
<h3 className={SECTION_TITLE_CLASS}>Organization Info</h3>
|
|
329
|
+
<p className="text-xs text-neutral-400">
|
|
330
|
+
Citation-friendly facts emitted as schema.org structured data and in
|
|
331
|
+
the <code>/llms.txt</code> summary. AI assistants quote these directly
|
|
332
|
+
when answering questions about your organization.
|
|
333
|
+
</p>
|
|
334
|
+
|
|
335
|
+
<div className="grid grid-cols-2 gap-4">
|
|
336
|
+
<div className="space-y-1">
|
|
337
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
338
|
+
Founded (year)
|
|
339
|
+
</label>
|
|
340
|
+
<input
|
|
341
|
+
type="number"
|
|
342
|
+
value={foundingYear ?? ""}
|
|
343
|
+
onChange={(e) => {
|
|
344
|
+
const v = e.target.value;
|
|
345
|
+
setFoundingYear(v === "" ? undefined : Number(v));
|
|
346
|
+
}}
|
|
347
|
+
min={1800}
|
|
348
|
+
max={new Date().getFullYear()}
|
|
349
|
+
placeholder="2018"
|
|
350
|
+
className={INPUT_CLASS}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
<div className="space-y-1">
|
|
354
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
355
|
+
Contact Email
|
|
356
|
+
</label>
|
|
357
|
+
<input
|
|
358
|
+
type="email"
|
|
359
|
+
value={contactEmail}
|
|
360
|
+
onChange={(e) => setContactEmail(e.target.value)}
|
|
361
|
+
placeholder="hello@example.com"
|
|
362
|
+
className={INPUT_CLASS}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<div className="grid grid-cols-2 gap-4">
|
|
368
|
+
<div className="space-y-1">
|
|
369
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
370
|
+
City
|
|
371
|
+
</label>
|
|
372
|
+
<input
|
|
373
|
+
type="text"
|
|
374
|
+
value={addressLocality}
|
|
375
|
+
onChange={(e) => setAddressLocality(e.target.value)}
|
|
376
|
+
placeholder="Barcelona"
|
|
377
|
+
className={INPUT_CLASS}
|
|
378
|
+
/>
|
|
379
|
+
</div>
|
|
380
|
+
<div className="space-y-1">
|
|
381
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
382
|
+
Country
|
|
383
|
+
</label>
|
|
384
|
+
<input
|
|
385
|
+
type="text"
|
|
386
|
+
value={addressCountry}
|
|
387
|
+
onChange={(e) => setAddressCountry(e.target.value)}
|
|
388
|
+
placeholder="Spain"
|
|
389
|
+
className={INPUT_CLASS}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div className="space-y-1">
|
|
395
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
396
|
+
Areas of Expertise
|
|
397
|
+
</label>
|
|
398
|
+
<input
|
|
399
|
+
type="text"
|
|
400
|
+
value={keywordsText}
|
|
401
|
+
onChange={(e) => setKeywordsText(e.target.value)}
|
|
402
|
+
placeholder="CGI, Motion Graphics, 3D Animation"
|
|
403
|
+
className={INPUT_CLASS}
|
|
169
404
|
/>
|
|
170
405
|
<p className="text-xs text-neutral-400">
|
|
171
|
-
|
|
172
|
-
|
|
406
|
+
Comma-separated list of services / specialties. Emitted as
|
|
407
|
+
schema.org knowsAbout — helps LLMs cite you for related queries.
|
|
173
408
|
</p>
|
|
174
409
|
</div>
|
|
175
410
|
|
|
176
|
-
{/*
|
|
177
|
-
|
|
411
|
+
{/* ============================================================
|
|
412
|
+
SEARCH ENGINE VERIFICATION
|
|
413
|
+
============================================================ */}
|
|
414
|
+
|
|
415
|
+
<h3 className={SECTION_TITLE_CLASS}>Search Engine Verification</h3>
|
|
416
|
+
<p className="text-xs text-neutral-400">
|
|
417
|
+
Connect your site to free webmaster tools to see how it performs in
|
|
418
|
+
search. Paste the verification code from each tool below (just the
|
|
419
|
+
code, not the full <code><meta></code> tag).
|
|
420
|
+
</p>
|
|
421
|
+
|
|
422
|
+
<div className="space-y-1">
|
|
423
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
424
|
+
Google Search Console
|
|
425
|
+
</label>
|
|
426
|
+
<input
|
|
427
|
+
type="text"
|
|
428
|
+
value={verGoogle}
|
|
429
|
+
onChange={(e) => setVerGoogle(e.target.value)}
|
|
430
|
+
placeholder="abc123-verification-code-from-google"
|
|
431
|
+
className={INPUT_CLASS}
|
|
432
|
+
/>
|
|
433
|
+
<p className="text-xs text-neutral-400">
|
|
434
|
+
From{" "}
|
|
435
|
+
<a
|
|
436
|
+
href="https://search.google.com/search-console"
|
|
437
|
+
target="_blank"
|
|
438
|
+
rel="noopener noreferrer"
|
|
439
|
+
className="text-[#3580f9] hover:underline"
|
|
440
|
+
>
|
|
441
|
+
Search Console
|
|
442
|
+
</a>{" "}
|
|
443
|
+
→ Settings → Ownership verification → HTML tag method.
|
|
444
|
+
</p>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<div className="space-y-1">
|
|
448
|
+
<label className="text-xs font-medium text-neutral-500">
|
|
449
|
+
Bing Webmaster
|
|
450
|
+
</label>
|
|
451
|
+
<input
|
|
452
|
+
type="text"
|
|
453
|
+
value={verBing}
|
|
454
|
+
onChange={(e) => setVerBing(e.target.value)}
|
|
455
|
+
placeholder="abc123-verification-code-from-bing"
|
|
456
|
+
className={INPUT_CLASS}
|
|
457
|
+
/>
|
|
458
|
+
<p className="text-xs text-neutral-400">
|
|
459
|
+
From{" "}
|
|
460
|
+
<a
|
|
461
|
+
href="https://www.bing.com/webmasters"
|
|
462
|
+
target="_blank"
|
|
463
|
+
rel="noopener noreferrer"
|
|
464
|
+
className="text-[#3580f9] hover:underline"
|
|
465
|
+
>
|
|
466
|
+
Bing Webmaster Tools
|
|
467
|
+
</a>{" "}
|
|
468
|
+
→ Site verification → Meta tag method.
|
|
469
|
+
</p>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{/* ============================================================
|
|
473
|
+
SAVE
|
|
474
|
+
============================================================ */}
|
|
475
|
+
|
|
476
|
+
<div className="flex justify-end pt-4">
|
|
178
477
|
<button
|
|
179
478
|
onClick={handleSave}
|
|
180
479
|
disabled={saving}
|