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 @@ -205,7 +205,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(`${base}/r/`) && !page.data.shared}
<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
33 changes: 33 additions & 0 deletions src/routes/conversation/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import type { v4 } from "uuid";
import { useSettingsStore } from "$lib/stores/settings.js";
import { enabledServers } from "$lib/stores/mcpServers";
import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
import { browser } from "$app/environment";
import {
addBackgroundGeneration,
Expand Down Expand Up @@ -550,16 +551,48 @@
$loading = false;
});

const publicConfig = usePublicConfig();

let title = $derived.by(() => {
const rawTitle = conversations.find((conv) => conv.id === page.params.id)?.title ?? data.title;
return rawTitle ? rawTitle.charAt(0).toUpperCase() + rawTitle.slice(1) : rawTitle;
});

let ogImageUrl = $derived.by(() => {
if (!data.shared) return undefined;
// For imported conversations, the share ID is in the fromShare query param;
// for direct shared views, the share ID is the 7-char page param.
const shareId = page.url.searchParams.get("fromShare") || page.params.id;
return `${publicConfig.PUBLIC_ORIGIN || page.url.origin}${base}/r/${shareId}/thumbnail.png`;
});
</script>

<svelte:window onkeydown={handleKeydown} />

<svelte:head>
<title>{title}</title>
{#if data.shared && ogImageUrl}
<meta property="og:title" content="{title} - {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={ogImageUrl} />
<meta property="og:image:alt" content={title} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="648" />
<meta property="og:url" content={page.url.href} />
<meta property="og:site_name" content={publicConfig.PUBLIC_APP_NAME} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title} - {publicConfig.PUBLIC_APP_NAME}" />
<meta
name="twitter:description"
content="Check out this conversation on {publicConfig.PUBLIC_APP_NAME}"
/>
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:image:alt" content={title} />
{/if}
</svelte:head>

<ChatWindow
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
68 changes: 68 additions & 0 deletions src/routes/r/[id]/thumbnail.png/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 } }
);

if (!shared) {
return new Response("Not Found", { status: 404 });
}

const title = shared.title || "Untitled Conversation";

const renderedComponent = render(ConversationThumbnail, {
props: {
title,
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",
},
});
};
48 changes: 48 additions & 0 deletions src/routes/r/[id]/thumbnail.png/ConversationThumbnail.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script lang="ts">
import logo from "../../../../../static/huggingchat/fulltext-logo.svg?raw";

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

let { title, 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);"
>
<!-- Shared conversation label -->
<div
style="display: flex; align-items: center; justify-content: center; padding: 14px 40px; border-radius: 9999px; background-color: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); margin-bottom: 40px; font-size: 32px; font-weight: 700; color: rgba(255,255,255,0.8); letter-spacing: 0.02em;"
>
Shared conversation
</div>

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

<!-- HuggingChat branding -->
{#if isHuggingChat}
<div
style="display: flex; align-items: center; margin-top: 40px; font-size: 28px; color: white;"
>
<!-- eslint-disable-next-line -->
{@html logo}
</div>
{/if}
</div>
Loading