Skip to content
Open
2 changes: 1 addition & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@

<!-- use those meta tags everywhere except on special listing pages -->
<!-- feel free to refacto if there's a better way -->
{#if !page.url.pathname.includes("/models/")}
{#if !page.url.pathname.includes("/models/") && !page.url.pathname.startsWith("/r/")}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make /r/ meta-tag exclusion base-path aware

I checked svelte.config.js and kit.paths.base is configurable via APP_BASE, so page.url.pathname can be prefixed (for example /chat/r/<id>). With the current pathname.startsWith('/r/') guard, those deployments won't skip the default OG/Twitter tags, so /r/[id] pages render both global and share-specific metadata and crawlers may pick the wrong preview title/image.

Useful? React with 👍 / 👎.

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{publicConfig.PUBLIC_APP_NAME} - Chat with AI models" />
<meta name="twitter:description" content={publicConfig.PUBLIC_APP_DESCRIPTION} />
Expand Down
26 changes: 26 additions & 0 deletions src/routes/r/[id]/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { PageServerLoad } from "./$types";
import { collections } from "$lib/server/database";
import { config } from "$lib/server/config";
import { base } from "$app/paths";

export const load: PageServerLoad = async ({ params, url }) => {
const shareId = params.id;

const shared = await collections.sharedConversations.findOne(
{ _id: shareId },
{ projection: { title: 1, model: 1 } }
);

const shareTitle = shared?.title || "Shared Conversation";
const shareModel = shared?.model ?? "";
const origin = config.get("PUBLIC_ORIGIN") || url.origin;
const appName = config.get("PUBLIC_APP_NAME") || "HuggingChat";

return {
shareTitle,
shareModel,
shareId,
appName,
ogImageUrl: `${origin}${base}/r/${shareId}/thumbnail.png`,
};
};
72 changes: 72 additions & 0 deletions src/routes/r/[id]/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import { useAPIClient, handleResponse } from "$lib/APIClient";
import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";

let { data } = $props();

const publicConfig = usePublicConfig();

onMount(async () => {
const leafId = page.url.searchParams.get("leafId");
const shareId = page.params.id;

// If logged in, try to import the share
if (data.loginEnabled && data.user && shareId) {
const client = useAPIClient();
try {
const result = await client.conversations["import-share"]
.post({ shareId })
.then(handleResponse);
if (result.conversationId) {
await goto(
`${base}/conversation/${result.conversationId}?leafId=${leafId ?? ""}&fromShare=${shareId}`,
{ replaceState: true }
);
return;
}
} catch {
// Fall through to view-only mode
}
}

// Not logged in or import failed: view-only mode
await goto(`${base}/conversation/${shareId}${leafId ? `?leafId=${leafId}` : ""}`, {
replaceState: true,
});
});
</script>

<svelte:head>
<title>{data.shareTitle} - {publicConfig.PUBLIC_APP_NAME}</title>
<meta property="og:title" content="{data.shareTitle} - {publicConfig.PUBLIC_APP_NAME}" />
<meta property="og:type" content="website" />
<meta
property="og:description"
content="Check out this conversation on {publicConfig.PUBLIC_APP_NAME}"
/>
<meta property="og:image" content={data.ogImageUrl} />
<meta property="og:image:alt" content={data.shareTitle} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="648" />
<meta
property="og:url"
content="{publicConfig.PUBLIC_ORIGIN || page.url.origin}{base}/r/{data.shareId}"
/>
<meta property="og:site_name" content={publicConfig.PUBLIC_APP_NAME} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{data.shareTitle} - {publicConfig.PUBLIC_APP_NAME}" />
<meta
name="twitter:description"
content="Check out this conversation on {publicConfig.PUBLIC_APP_NAME}"
/>
<meta name="twitter:image" content={data.ogImageUrl} />
<meta name="twitter:image:alt" content={data.shareTitle} />
</svelte:head>

<div class="flex h-full items-center justify-center">
<div class="animate-pulse text-gray-400">Loading conversation...</div>
</div>
14 changes: 12 additions & 2 deletions src/routes/r/[id]/+page.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { redirect } from "@sveltejs/kit";
import { useAPIClient, handleResponse } from "$lib/APIClient";
import { base } from "$app/paths";
import { browser } from "$app/environment";
import type { PageLoad } from "./$types";

export const load: PageLoad = async ({ params, url, fetch, parent }) => {
const leafId = url.searchParams.get("leafId");
export const load: PageLoad = async ({ params, url, fetch, parent, data }) => {
const parentData = await parent();

// During SSR, don't redirect — let +page.svelte render OG meta tags for crawlers.
// The client-side redirect happens in +page.svelte's onMount instead.
// Pass through both layout data and server data (shareTitle, ogImageUrl, etc.)
if (!browser) {
return { ...parentData, ...data };
}

// --- Client-side only below ---
const leafId = url.searchParams.get("leafId");

// If logged in, import the share and redirect to the new conversation
if (parentData.loginEnabled && parentData.user && params.id) {
const client = useAPIClient({ fetch, origin: url.origin });
Expand Down
66 changes: 66 additions & 0 deletions src/routes/r/[id]/thumbnail.png/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import ConversationThumbnail from "./ConversationThumbnail.svelte";
import type { RequestHandler } from "@sveltejs/kit";

import { Resvg } from "@resvg/resvg-js";
import satori from "satori";
import { html } from "satori-html";

import InterRegular from "$lib/server/fonts/Inter-Regular.ttf";
import InterBold from "$lib/server/fonts/Inter-Bold.ttf";
import { collections } from "$lib/server/database";
import { config } from "$lib/server/config";
import { render } from "svelte/server";

export const GET: RequestHandler = async ({ params }) => {
const shareId = params.id;

const shared = await collections.sharedConversations.findOne(
{ _id: shareId },
{ projection: { title: 1, model: 1 } }
);

const title = shared?.title || "Untitled Conversation";
const modelName = shared?.model ? (shared.model.split("/").pop() ?? shared.model) : "";

const renderedComponent = render(ConversationThumbnail, {
props: {
title,
modelName,
isHuggingChat: config.isHuggingChat,
},
});

const reactLike = html(
"<style>" + renderedComponent.head + "</style>" + renderedComponent.body
) as unknown as never;

const svg = await satori(reactLike, {
width: 1200,
height: 648,
fonts: [
{
name: "Inter",
data: InterRegular as unknown as ArrayBuffer,
weight: 500,
},
{
name: "Inter",
data: InterBold as unknown as ArrayBuffer,
weight: 700,
},
],
});

const png = new Resvg(svg, {
fitTo: { mode: "original" },
})
.render()
.asPng();

return new Response(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800",
},
});
};
64 changes: 64 additions & 0 deletions src/routes/r/[id]/thumbnail.png/ConversationThumbnail.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<script lang="ts">
import logo from "../../../../../static/huggingchat/fulltext-logo.svg?raw";

interface Props {
title: string;
modelName?: string;
isHuggingChat?: boolean;
}

let { title, modelName = "", isHuggingChat = false }: Props = $props();

// Satori doesn't reliably support text-overflow: ellipsis,
// so we truncate manually for predictable rendering.
function truncateTitle(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 1).trimEnd() + "\u2026";
}

const displayTitle = truncateTitle(title, 80);
</script>

<div
class="flex h-[648px] w-[1200px] flex-col items-center justify-center bg-black text-white"
style="background-image: url(https://cdn-uploads.huggingface.co/production/uploads/5f17f0a0925b9863e28ad517/L4XVRJ7MsfFDD7ROx_geO.png);"
>
<!-- Chat bubble icon -->
<div
style="display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 18px; background: linear-gradient(135deg, rgba(255,210,0,0.25), rgba(255,150,0,0.15)); border: 1px solid rgba(255,210,0,0.3); margin-bottom: 32px;"
>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2C6.48 2 2 5.58 2 10c0 2.24 1.12 4.26 2.94 5.7L4 22l4.73-2.84C9.77 19.72 10.86 20 12 20c5.52 0 10-3.58 10-8s-4.48-8-10-8z"
fill="rgba(255,210,0,0.9)"
/>
</svg>
</div>

<!-- Conversation title -->
<div
style="font-size: 48px; font-weight: 700; color: white; text-align: center; max-width: 1000px; line-height: 1.25; overflow: hidden; display: flex; align-items: center; justify-content: center;"
>
{displayTitle}
</div>

<!-- Model name subtitle -->
{#if modelName}
<div style="font-size: 22px; color: rgba(255,255,255,0.5); margin-top: 20px; font-weight: 500;">
{modelName}
</div>
{/if}

<!-- HuggingChat branding -->
{#if isHuggingChat}
<div
style="display: flex; align-items: center; margin-top: 32px; font-size: 28px; color: white;"
>
<div style="margin-right: 12px; font-size: 22px; color: rgba(255,255,255,0.7);">
Shared on
</div>
<!-- eslint-disable-next-line -->
{@html logo}
</div>
{/if}
</div>
Loading