mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
implement site and page-level SEO meta tags with social preview image support
This commit is contained in:
@@ -162,6 +162,9 @@ export interface PageDashboardData {
|
||||
monitorTags: string[];
|
||||
monitorGroupMembersByTag: Record<string, string[]>;
|
||||
pageDetails: PageRecordTyped;
|
||||
socialPagePreviewImage?: string;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
}
|
||||
|
||||
const BuildPageStatus = (latestData: Array<{ status?: string | null; latency?: number | null }>, nowTs: number) => {
|
||||
@@ -377,6 +380,22 @@ export const GetPageDashboardData = async (
|
||||
monitorGroupMembersByTag[monitor.tag] = groupData.monitors.map((member) => member.tag);
|
||||
}
|
||||
|
||||
let socialPagePreviewImage: string | undefined = layoutData.socialPreviewImage;
|
||||
let metaPageTitle: string | undefined = layoutData.metaSiteTitle;
|
||||
let metaPageDescription: string | undefined = layoutData.metaSiteDescription;
|
||||
if (!!pageDetails.page_settings_json) {
|
||||
try {
|
||||
const pageSettings = JSON.parse(pageDetails.page_settings_json);
|
||||
if (pageSettings) {
|
||||
socialPagePreviewImage = pageSettings.socialPreviewImage || layoutData.socialPreviewImage;
|
||||
metaPageTitle = pageSettings.metaTitle || layoutData.metaSiteTitle;
|
||||
metaPageDescription = pageSettings.metaDescription || layoutData.metaSiteDescription;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parsing errors and fallback to layout data or defaults
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pageStatus,
|
||||
ongoingIncidents,
|
||||
@@ -384,5 +403,8 @@ export const GetPageDashboardData = async (
|
||||
monitorTags,
|
||||
monitorGroupMembersByTag,
|
||||
pageDetails: pageDetailsTyped,
|
||||
socialPagePreviewImage,
|
||||
metaPageTitle,
|
||||
metaPageDescription,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface LayoutServerData {
|
||||
customCSS?: string;
|
||||
globalPageVisibilitySettings: GlobalPageVisibilitySettings;
|
||||
dateAndTimeFormat: SiteDateTimeFormat;
|
||||
metaSiteTitle?: string;
|
||||
metaSiteDescription?: string;
|
||||
}
|
||||
|
||||
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
|
||||
@@ -139,5 +141,7 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
customCSS: siteData.customCSS,
|
||||
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
|
||||
dateAndTimeFormat: siteData.dateAndTimeFormat || seedSiteData.dateAndTimeFormat,
|
||||
metaSiteTitle: siteData.metaSiteTitle,
|
||||
metaSiteDescription: siteData.metaSiteDescription,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface SiteDataTransformed {
|
||||
globalPageVisibilitySettings?: GlobalPageVisibilitySettings;
|
||||
pageOrderingSettings?: PageOrderingSettings;
|
||||
dateAndTimeFormat?: SiteDateTimeFormat;
|
||||
metaSiteTitle?: string;
|
||||
metaSiteDescription?: string;
|
||||
}
|
||||
|
||||
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
|
||||
|
||||
@@ -276,4 +276,14 @@ export const siteDataKeys: SiteDataKey[] = [
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
{
|
||||
key: "metaSiteTitle",
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
key: "metaSiteDescription",
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -444,6 +444,9 @@ export interface PageSettingsType {
|
||||
mobile: number;
|
||||
};
|
||||
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
}
|
||||
|
||||
export interface PageRecordTyped {
|
||||
|
||||
@@ -295,6 +295,10 @@
|
||||
"group": "v4.x",
|
||||
"collapsible": false,
|
||||
"pages": [
|
||||
{
|
||||
"title": "v4.0.19",
|
||||
"content": "v4/changelogs/v4.0.19"
|
||||
},
|
||||
{
|
||||
"title": "v4.0.18",
|
||||
"content": "v4/changelogs/v4.0.18"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: v4.0.19 Changelog
|
||||
description: See what's new in Kener v4.0.19, including new features, improvements, and bug fixes
|
||||
---
|
||||
|
||||
## New features {#new-features}
|
||||
|
||||
### Site-level SEO meta tags {#site-level-seo}
|
||||
|
||||
Admins can now set a custom **meta title** and **meta description** for the entire status page from **Manage → Site Configurations → Social Preview & SEO**. These values are used as defaults for all pages and control how the site appears in search engine results and social link previews.
|
||||
|
||||
Both fields are optional — leave them empty to use the existing page title and header as defaults.
|
||||
|
||||
| Field | Effect |
|
||||
| :----------------- | :---------------------------------------------------- |
|
||||
| `Meta Title` | Sets the `<title>` tag shown in search results |
|
||||
| `Meta Description` | Sets the `<meta name="description">` tag for snippets |
|
||||
|
||||
The **social preview image** upload has been merged into this same card. A single Save button persists the image, title, and description together.
|
||||
|
||||
### Page-level SEO meta tags {#page-level-seo}
|
||||
|
||||
Each page now supports its own **meta title**, **meta description**, and **social preview image** via **Manage → Pages → [page] → Social Preview & SEO**. These override the site-level defaults for that specific page.
|
||||
|
||||
The override hierarchy is:
|
||||
|
||||
1. **Page-level** setting from `page_settings_json` (if set)
|
||||
2. **Site-level** setting from Site Configurations (if set)
|
||||
3. **Auto-generated** defaults (page title + site name, page header)
|
||||
|
||||
| Meta tag | Page-level key | Site-level fallback | Auto fallback |
|
||||
| :--------------------- | :------------------- | :-------------------- | :-------------------------- |
|
||||
| `<title>` | `metaTitle` | `metaSiteTitle` | `{page_title} - {siteName}` |
|
||||
| `<meta description>` | `metaDescription` | `metaSiteDescription` | Page header text |
|
||||
| `<meta og:image>` | `socialPreviewImage` | `socialPreviewImage` | None |
|
||||
| `<meta twitter:image>` | `socialPreviewImage` | `socialPreviewImage` | None |
|
||||
@@ -64,6 +64,23 @@ Choose one layout:
|
||||
|
||||
Click **Save Preferences** after changes.
|
||||
|
||||
## Social preview and SEO {#social-preview-and-seo}
|
||||
|
||||
Each page can override the site-level SEO defaults set in [Site Configuration](/docs/v4/setup/site-configuration#social-preview-and-seo).
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------- | ------------------------------------------------------------- |
|
||||
| `Meta Title` | Custom `<title>` and `og:title` for this page. |
|
||||
| `Meta Description` | Custom `<meta name="description">` and `og:description`. |
|
||||
| `Social Preview Image` | Custom `og:image` and `twitter:image` shown in link previews. |
|
||||
|
||||
When a field is left empty, the site-level value is used. When the site-level value is also empty, the page title and header are used as automatic fallbacks.
|
||||
|
||||
**Override hierarchy**: Page setting → Site setting → Auto-generated from page title/header.
|
||||
|
||||
> [!TIP]
|
||||
> Upload a 1200×630 image for best results across social platforms.
|
||||
|
||||
## Delete a page {#delete-a-page}
|
||||
|
||||
Non-home pages can be deleted from **Danger Zone**.
|
||||
|
||||
@@ -12,18 +12,20 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
|
||||
3. Configure **Navigation Menu**.
|
||||
4. Set **Monitor Sub Menu Options**.
|
||||
5. Configure **Global Page Visibility Settings**.
|
||||
6. Configure **Data Retention Policy**.
|
||||
7. Configure **Event Display Settings**.
|
||||
6. Configure **Social Preview & SEO**.
|
||||
7. Configure **Data Retention Policy**.
|
||||
8. Configure **Event Display Settings**.
|
||||
|
||||
## Runtime impact map {#runtime-impact-map}
|
||||
|
||||
| Setting area | Stored key | Runtime impact |
|
||||
| ---------------------------- | ------------------------------------ | ------------------------------------------------------------------- |
|
||||
| Site name / URL / logo / nav | `siteName`, `siteURL`, `logo`, `nav` | Rendered in top navbar branding and nav links |
|
||||
| Favicon | `favicon` | Used in `<head>` as page icon |
|
||||
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
|
||||
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
|
||||
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
|
||||
| Setting area | Stored key | Runtime impact |
|
||||
| ---------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||
| Site name / URL / logo / nav | `siteName`, `siteURL`, `logo`, `nav` | Rendered in top navbar branding and nav links |
|
||||
| Favicon | `favicon` | Used in `<head>` as page icon |
|
||||
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
|
||||
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
|
||||
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
|
||||
| Social preview & SEO | `metaSiteTitle`, `metaSiteDescription`, `socialPreviewImage` | Default `og:title`, `og:description`, `og:image` for all pages |
|
||||
|
||||
## Monitor sub menu options {#monitor-sub-menu-options}
|
||||
|
||||
@@ -80,6 +82,21 @@ This affects:
|
||||
- event sections on status pages
|
||||
- notifications payload API used by the UI
|
||||
|
||||
## Social preview and SEO {#social-preview-and-seo}
|
||||
|
||||
The **Social Preview & SEO** card sets site-wide defaults for meta tags used in search engines and link previews.
|
||||
|
||||
| Field | Stored key | Meta tags affected |
|
||||
| ---------------------- | --------------------- | --------------------------------------------- |
|
||||
| `Meta Title` | `metaSiteTitle` | `<title>`, `og:title` |
|
||||
| `Meta Description` | `metaSiteDescription` | `<meta name="description">`, `og:description` |
|
||||
| `Social Preview Image` | `socialPreviewImage` | `og:image`, `twitter:image` |
|
||||
|
||||
These values are used as defaults for every page. Individual pages can override them — see [Pages → Social Preview & SEO](/docs/v4/pages#social-preview-and-seo).
|
||||
|
||||
> [!TIP]
|
||||
> Upload a 1200×630 image for best results across social platforms.
|
||||
|
||||
## Verify changes {#verify-changes}
|
||||
|
||||
- Update site name/logo/nav and refresh home page.
|
||||
@@ -90,3 +107,4 @@ This affects:
|
||||
- notifications calendar opens page-scoped events for the current month.
|
||||
- Change event display settings and verify incident/maintenance visibility.
|
||||
- Set retention policy and confirm scheduler logs in server output.
|
||||
- Set meta title/description and social preview image, then check `<meta>` tags in page source.
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<Toaster />
|
||||
|
||||
<svelte:head>
|
||||
<title>Kener Status</title>
|
||||
<link rel="icon" href={data.favicon} />
|
||||
{#if data.font?.cssSrc}
|
||||
<link rel="stylesheet" href={data.font.cssSrc} />
|
||||
@@ -44,11 +43,6 @@
|
||||
${data.customCSS || ""}
|
||||
</style>`}
|
||||
<script src={clientResolver(resolve, "/capture.js")}></script>
|
||||
<!-- social preview meta tags -->
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<main class="kener-public">
|
||||
<!-- Nav -->
|
||||
|
||||
@@ -113,7 +113,25 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
|
||||
<title>{data.metaPageTitle || (data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
|
||||
{#if data.metaPageTitle}
|
||||
<title>{data.metaPageTitle}</title>
|
||||
{:else if data.pageDetails?.page_title}
|
||||
<title>{data.pageDetails.page_title} - {data.siteName}</title>
|
||||
{:else}
|
||||
<title>{data.siteName} - Status Page</title>
|
||||
{/if}
|
||||
{#if data.metaPageDescription}
|
||||
<meta name="description" content={data.metaPageDescription} />
|
||||
{:else if data.pageDetails?.page_header}
|
||||
<meta name="description" content={data.pageDetails.page_header} />
|
||||
{:else}
|
||||
<meta name="description" content={data.pageDetails.page_title + " - Status Page"} />
|
||||
{/if}
|
||||
{#if data.socialPagePreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<!-- page title -->
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let pageSettings = $derived(data.pageDetails.page_settings);
|
||||
let barCount = $derived.by(() =>
|
||||
data.isMobile
|
||||
@@ -114,7 +113,25 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
|
||||
<title>{data.metaPageTitle || (data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
|
||||
{#if data.metaPageTitle}
|
||||
<title>{data.metaPageTitle}</title>
|
||||
{:else if data.pageDetails?.page_title}
|
||||
<title>{data.pageDetails.page_title} - {data.siteName}</title>
|
||||
{:else}
|
||||
<title>{data.siteName} - Status Page</title>
|
||||
{/if}
|
||||
{#if data.metaPageDescription}
|
||||
<meta name="description" content={data.metaPageDescription} />
|
||||
{:else if data.pageDetails?.page_header}
|
||||
<meta name="description" content={data.pageDetails.page_header} />
|
||||
{:else}
|
||||
<meta name="description" content={data.pageDetails.page_title + " - Status Page"} />
|
||||
{/if}
|
||||
{#if data.socialPagePreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<!-- page title -->
|
||||
|
||||
@@ -159,6 +159,10 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{currentMonth} - Maintenances & Incidents - {data.siteName}</title>
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -159,6 +159,10 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{currentMonth} - Maintenances & Incidents - {data.siteName}</title>
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
{#if data.comments.length > 0}
|
||||
<meta name="description" content={data.comments[0].comment} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
{#if data.maintenance.description}
|
||||
<meta name="description" content={data.maintenance.description} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
{#if data.monitorDescription}
|
||||
<meta name="description" content={data.monitorDescription} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<div class="flex flex-col gap-3">
|
||||
<ThemePlus monitor_tags={[data.monitorTag]} embedMonitorTag={data.monitorTag} />
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { Switch } from "$lib/components/ui/switch/index.js";
|
||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||
import { toast } from "svelte-sonner";
|
||||
import Loader from "@lucide/svelte/icons/loader";
|
||||
import SaveIcon from "@lucide/svelte/icons/save";
|
||||
@@ -51,6 +52,7 @@
|
||||
let saving = $state(false);
|
||||
let savingMonitors = $state(false);
|
||||
let uploadingLogo = $state(false);
|
||||
let uploadingSocialPreview = $state(false);
|
||||
|
||||
// Page data
|
||||
let currentPage = $state<PageWithMonitors | null>(null);
|
||||
@@ -411,6 +413,60 @@
|
||||
formData.page_logo = "";
|
||||
}
|
||||
|
||||
function clearSocialPreview() {
|
||||
pageSettings.socialPagePreviewImage = "";
|
||||
}
|
||||
|
||||
async function handleSocialPreviewUpload(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error("Invalid file type. Allowed: PNG, JPG, WebP");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > GC.MAX_UPLOAD_BYTES) {
|
||||
toast.error(`File too large. Maximum size is ${GC.MAX_UPLOAD_BYTES / (1024 * 1024)}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingSocialPreview = true;
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const response = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "uploadImage",
|
||||
data: {
|
||||
base64,
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
maxWidth: 1200,
|
||||
maxHeight: 630,
|
||||
prefix: "page_social_"
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
pageSettings.socialPagePreviewImage = result.url;
|
||||
toast.success("Social preview image uploaded");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Failed to upload social preview image");
|
||||
} finally {
|
||||
uploadingSocialPreview = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function savePageSettings() {
|
||||
if (!currentPage) return;
|
||||
|
||||
@@ -786,6 +842,102 @@
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Social Preview & SEO Card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Social Preview & SEO</Card.Title>
|
||||
<Card.Description
|
||||
>Optional social preview image and meta tags for this page. Leave empty to use site defaults.</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Preview -->
|
||||
<div class="bg-muted flex h-32 w-64 items-center justify-center rounded-lg border">
|
||||
{#if pageSettings.socialPagePreviewImage}
|
||||
<img
|
||||
src={clientResolver(resolve, pageSettings.socialPagePreviewImage)}
|
||||
alt="Social preview"
|
||||
class="h-full w-full rounded-lg object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<ImageIcon class="text-muted-foreground h-8 w-8" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Upload Controls -->
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploadingSocialPreview}
|
||||
onclick={() => document.getElementById("page-social-preview-input")?.click()}
|
||||
>
|
||||
{#if uploadingSocialPreview}
|
||||
<Loader class="h-4 w-4 animate-spin" />
|
||||
Uploading...
|
||||
{:else}
|
||||
<UploadIcon class="h-4 w-4" />
|
||||
Upload Social Preview
|
||||
{/if}
|
||||
</Button>
|
||||
<input
|
||||
id="page-social-preview-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
class="hidden"
|
||||
onchange={handleSocialPreviewUpload}
|
||||
disabled={uploadingSocialPreview}
|
||||
/>
|
||||
{#if pageSettings.socialPagePreviewImage}
|
||||
<Button variant="ghost" size="sm" onclick={clearSocialPreview}>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pageSettings.socialPagePreviewImage}
|
||||
<p class="text-muted-foreground truncate text-xs">{pageSettings.socialPagePreviewImage}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Optional. Leave empty to use site default.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="page-metaPageTitle">Meta Title</Label>
|
||||
<Input
|
||||
id="page-metaPageTitle"
|
||||
type="text"
|
||||
bind:value={pageSettings.metaPageTitle}
|
||||
placeholder="Custom page title for search engines"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Overrides the default page title in search results</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="page-metaPageDescription">Meta Description</Label>
|
||||
<Textarea
|
||||
id="page-metaPageDescription"
|
||||
bind:value={pageSettings.metaPageDescription}
|
||||
placeholder="Custom description for search engines"
|
||||
rows={3}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Shown as the snippet text in search engine results</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<Button onclick={savePageSettings} disabled={savingSettings}>
|
||||
{#if savingSettings}
|
||||
<Loader class="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<SaveIcon class="h-4 w-4" />
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Danger Zone Card (only for non-home pages) -->
|
||||
{#if currentPage.page_path !== ""}
|
||||
<Card.Root class="border-destructive">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { Switch } from "$lib/components/ui/switch/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
@@ -97,6 +98,8 @@
|
||||
});
|
||||
|
||||
let eventDisplaySettings = $state<EventDisplaySettings>(structuredClone(defaultEventDisplaySettings));
|
||||
let metaSiteTitle = $state("");
|
||||
let metaSiteDescription = $state("");
|
||||
let currentOrigin = $state("");
|
||||
|
||||
function onForceExclusivityChange(checked: boolean | "indeterminate") {
|
||||
@@ -204,6 +207,9 @@
|
||||
} else {
|
||||
eventDisplaySettings = structuredClone(defaultEventDisplaySettings);
|
||||
}
|
||||
|
||||
metaSiteTitle = data.metaSiteTitle || "";
|
||||
metaSiteDescription = data.metaSiteDescription || "";
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Failed to load site data");
|
||||
@@ -300,7 +306,11 @@
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "storeSiteData",
|
||||
data: { socialPreviewImage: siteData.socialPreviewImage }
|
||||
data: {
|
||||
socialPreviewImage: siteData.socialPreviewImage,
|
||||
metaSiteTitle: metaSiteTitle,
|
||||
metaSiteDescription: metaSiteDescription
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -308,10 +318,10 @@
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success("Social preview image saved successfully");
|
||||
toast.success("Social preview & SEO settings saved successfully");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Failed to save social preview image");
|
||||
toast.error("Failed to save social preview & SEO settings");
|
||||
} finally {
|
||||
savingSocialPreviewImage = false;
|
||||
}
|
||||
@@ -805,11 +815,11 @@
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Social Preview Image Card -->
|
||||
<!-- Social Preview & SEO Card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Social Preview Image</Card.Title>
|
||||
<Card.Description>Upload an optional social preview image at least 640x320px or similar</Card.Description>
|
||||
<Card.Title>Social Preview & SEO</Card.Title>
|
||||
<Card.Description>Configure social preview image and meta tags for search engines</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
@@ -863,6 +873,27 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="metaSiteTitle">Meta Title</Label>
|
||||
<Input
|
||||
id="metaSiteTitle"
|
||||
type="text"
|
||||
bind:value={metaSiteTitle}
|
||||
placeholder="Custom page title for search engines"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Overrides the default page title in search results</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="metaSiteDescription">Meta Description</Label>
|
||||
<Textarea
|
||||
id="metaSiteDescription"
|
||||
bind:value={metaSiteDescription}
|
||||
placeholder="Custom description for search engines"
|
||||
rows={3}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Shown as the snippet text in search engine results</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<Button onclick={saveSocialPreviewImage} disabled={savingSocialPreviewImage} class="cursor-pointer">
|
||||
|
||||
Reference in New Issue
Block a user