better share modal

This commit is contained in:
Thomas G. Lopes 2025-06-19 01:07:27 +01:00
parent cbf387e07c
commit 16f689a598
9 changed files with 360 additions and 84 deletions

View file

@ -304,6 +304,21 @@ export const remove = mutation({
},
});
export const getPublicById = query({
args: {
conversation_id: v.id('conversations'),
},
handler: async (ctx, args) => {
const conversation = await ctx.db.get(args.conversation_id);
if (!conversation || !conversation.public) {
return null;
}
return conversation;
},
});
export const search = query({
args: {
session_token: v.string(),

View file

@ -169,6 +169,28 @@ export const updateMessage = mutation({
},
});
export const getByConversationPublic = query({
args: {
conversation_id: v.id('conversations'),
},
handler: async (ctx, args) => {
// First check if the conversation is public
const conversation = await ctx.db.get(args.conversation_id);
if (!conversation || !conversation.public) {
return null;
}
const messages = await ctx.db
.query('messages')
.withIndex('by_conversation', (q) => q.eq('conversation_id', args.conversation_id))
.order('asc')
.collect();
return messages;
},
});
export const updateError = mutation({
args: {
session_token: v.string(),

View file

@ -0,0 +1 @@
export { default as ShareButton } from './share-button.svelte';

View file

@ -0,0 +1,145 @@
<script lang="ts">
import { api } from '$lib/backend/convex/_generated/api.js';
import { type Id } from '$lib/backend/convex/_generated/dataModel.js';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte.js';
import { session } from '$lib/state/session.svelte.js';
import { useConvexClient } from 'convex-svelte';
import { Popover } from 'melt/builders';
import { ResultAsync } from 'neverthrow';
import { scale } from 'svelte/transition';
import CheckIcon from '~icons/lucide/check';
import CopyIcon from '~icons/lucide/copy';
import ExternalLinkIcon from '~icons/lucide/external-link';
import ShareIcon from '~icons/lucide/share';
import XIcon from '~icons/lucide/x';
const client = useConvexClient();
const clipboard = new UseClipboard();
let { conversationId } = $props<{
conversationId: Id<'conversations'>;
}>();
const conversationQuery = useCachedQuery(api.conversations.getById, () => ({
conversation_id: conversationId as Id<'conversations'>,
session_token: session.current?.session.token ?? '',
}));
let isPublic = $derived(Boolean(conversationQuery.data?.public));
let isToggling = $state(false);
let open = $state(false);
const popover = new Popover({
open: () => open,
onOpenChange: (v) => {
open = v;
},
floatingConfig: {
computePosition: { placement: 'bottom-end' },
},
});
const shareUrl = $derived.by(() => {
if (typeof window !== 'undefined') {
return `${window.location.origin}/share/${conversationId}`;
}
return '';
});
async function toggleSharing(newValue: boolean) {
if (!session.current?.session.token) return;
const prev = isPublic;
isPublic = newValue;
isToggling = true;
const result = await ResultAsync.fromPromise(
client.mutation(api.conversations.setPublic, {
conversation_id: conversationId,
public: newValue,
session_token: session.current.session.token,
}),
(e) => e
);
if (result.isErr()) {
// Revert the change if API call failed
isPublic = prev;
console.error('Error toggling sharing:', result.error);
}
isToggling = false;
}
function copyShareUrl() {
clipboard.copy(shareUrl);
}
</script>
<Tooltip>
{#snippet trigger(tooltip)}
<div {...tooltip.trigger}>
<Button {...popover.trigger} variant="ghost" size="icon" class="size-8">
<ShareIcon class="size-4" />
</Button>
</div>
{/snippet}
Share
</Tooltip>
<div
{...popover.content}
class="bg-popover border-border z-50 w-80 rounded-lg border p-4 shadow-lg"
>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">Share conversation</h3>
<Button onclick={() => (open = false)} variant="ghost" size="icon" class="size-6">
<XIcon class="size-4" />
</Button>
</div>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm font-medium">Public sharing</p>
<p class="text-muted-foreground text-xs">Anyone with the link can view this conversation</p>
</div>
<Switch bind:value={() => isPublic, (v) => toggleSharing(v)} disabled={isToggling} />
</div>
{#if isPublic}
<div class="space-y-2">
<p class="text-sm font-medium">Share link</p>
<div class="border-input bg-background flex items-center rounded-xl border px-3 py-2">
<span class="text-muted-foreground flex-1 truncate text-sm">{shareUrl}</span>
<Button onclick={copyShareUrl} variant="ghost" size="icon" class="ml-2 size-6 shrink-0">
{#if clipboard.status === 'success'}
<div in:scale={{ duration: 200, start: 0.8 }}>
<CheckIcon class="size-4" />
</div>
{:else}
<CopyIcon class="size-4" />
{/if}
</Button>
</div>
<a
href={shareUrl}
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors"
>
Open in new tab <ExternalLinkIcon class="size-3" />
</a>
</div>
{:else}
<p class="text-muted-foreground text-xs">
Enable public sharing to generate a shareable link
</p>
{/if}
</div>
</div>

View file

@ -19,7 +19,7 @@
<button
{...toggle.trigger}
class={cn(
'bg-muted-foreground/20 relative h-5 w-10 rounded-full transition-all',
'bg-muted-foreground/20 relative h-5 w-10 shrink-0 rounded-full transition-all',
{ 'bg-primary': toggle.value },
className
)}

View file

@ -3,15 +3,15 @@
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { Button } from '$lib/components/ui/button';
import { Search } from '$lib/components/ui/search';
import { models } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { Provider } from '$lib/types.js';
import { cn } from '$lib/utils/utils';
import ModelCard from './model-card.svelte';
import { Toggle } from 'melt/builders';
import XIcon from '~icons/lucide/x';
import PlusIcon from '~icons/lucide/plus';
import { models } from '$lib/state/models.svelte';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { cn } from '$lib/utils/utils';
import { Toggle } from 'melt/builders';
import PlusIcon from '~icons/lucide/plus';
import XIcon from '~icons/lucide/x';
import ModelCard from './model-card.svelte';
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
@ -30,14 +30,26 @@
disabled: true,
});
let initiallyEnabled = $state<string[]>([]);
$effect(() => {
if (Object.keys(models.enabled).length && initiallyEnabled.length === 0) {
initiallyEnabled = models
.from(Provider.OpenRouter)
.filter((m) => m.enabled)
.map((m) => m.id);
}
});
const openRouterModels = $derived(
fuzzysearch({
haystack: models.from(Provider.OpenRouter),
needle: search,
property: 'name',
}).sort((a, b) => {
if (a.enabled && !b.enabled) return -1;
if (!a.enabled && b.enabled) return 1;
const aEnabled = initiallyEnabled.includes(a.id);
const bEnabled = initiallyEnabled.includes(b.id);
if (aEnabled && !bEnabled) return -1;
if (!aEnabled && bEnabled) return 1;
return 0;
})
);

View file

@ -9,10 +9,10 @@
import { Button } from '$lib/components/ui/button';
import { ImageModal } from '$lib/components/ui/image-modal';
import { LightSwitch } from '$lib/components/ui/light-switch/index.js';
import { ShareButton } from '$lib/components/ui/share-button';
import * as Sidebar from '$lib/components/ui/sidebar';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte.js';
import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
import { models } from '$lib/state/models.svelte';
import { usePrompt } from '$lib/state/prompt.svelte.js';
@ -26,17 +26,14 @@
import { cn } from '$lib/utils/utils.js';
import { useConvexClient } from 'convex-svelte';
import { FileUpload, Popover } from 'melt/builders';
import { ResultAsync } from 'neverthrow';
import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed';
import { fade, scale } from 'svelte/transition';
import { fade } from 'svelte/transition';
import SendIcon from '~icons/lucide/arrow-up';
import CheckIcon from '~icons/lucide/check';
import ChevronDownIcon from '~icons/lucide/chevron-down';
import ImageIcon from '~icons/lucide/image';
import PanelLeftIcon from '~icons/lucide/panel-left';
import SearchIcon from '~icons/lucide/search';
import Settings2Icon from '~icons/lucide/settings-2';
import ShareIcon from '~icons/lucide/share';
import StopIcon from '~icons/lucide/square';
import UploadIcon from '~icons/lucide/upload';
import XIcon from '~icons/lucide/x';
@ -346,50 +343,6 @@
}
}
const clipboard = new UseClipboard();
let sharingStatus = $derived(clipboard.status);
async function shareConversation() {
if (currentConversationQuery.data?.public) {
clipboard.copy(page.url.toString());
return;
}
if (!page.params.id || !session.current?.session.token) return;
const result = await ResultAsync.fromPromise(
client.mutation(api.conversations.setPublic, {
conversation_id: page.params.id as Id<'conversations'>,
public: true,
session_token: session.current?.session.token ?? '',
}),
(e) => e
);
if (result.isErr()) {
sharingStatus = 'failure';
setTimeout(() => {
sharingStatus = undefined;
}, 1000);
}
clipboard.copy(page.url.toString());
}
// async function togglePublic() {
// if (!page.params.id || !session.current?.session.token) return;
//
// await ResultAsync.fromPromise(
// client.mutation(api.conversations.setPublic, {
// conversation_id: page.params.id as Id<'conversations'>,
// public: !currentConversationQuery.data?.public,
// session_token: session.current?.session.token ?? '',
// }),
// (e) => e
// );
// }
const textareaSize = new ElementSize(() => textarea);
let textareaWrapper = $state<HTMLDivElement>();
@ -473,32 +426,10 @@
)}
>
{#if page.params.id && currentConversationQuery.data}
<Tooltip>
{#snippet trigger(tooltip)}
<Button
onClickPromise={shareConversation}
variant="ghost"
size="icon"
class="bg-sidebar size-8"
{...tooltip.trigger}
>
{#if sharingStatus === 'success'}
<div in:scale={{ duration: 1000, start: 0.85 }}>
<CheckIcon tabindex={-1} />
<span class="sr-only">Copied</span>
</div>
{:else if sharingStatus === 'failure'}
<div in:scale={{ duration: 1000, start: 0.85 }}>
<XIcon tabindex={-1} />
<span class="sr-only">Failed to copy</span>
</div>
{:else}
<ShareIcon />
{/if}
</Button>
{/snippet}
Share
</Tooltip>
<ShareButton
conversationId={page.params.id as Id<'conversations'>}
isPublic={currentConversationQuery.data.public}
/>
{/if}
<Tooltip>
{#snippet trigger(tooltip)}

View file

@ -0,0 +1,32 @@
import { api } from '$lib/backend/convex/_generated/api.js';
import { type Id } from '$lib/backend/convex/_generated/dataModel.js';
import { ConvexHttpClient } from 'convex/browser';
import { error } from '@sveltejs/kit';
const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
export const load = async ({ params }: { params: { id: string } }) => {
try {
// Get the conversation without requiring authentication
const conversation = await client.query(api.conversations.getPublicById, {
conversation_id: params.id as Id<'conversations'>,
});
if (!conversation) {
error(404, 'Conversation not found or not shared publicly');
}
// Get messages for this conversation
const messages = await client.query(api.messages.getByConversationPublic, {
conversation_id: params.id as Id<'conversations'>,
});
return {
conversation,
messages,
};
} catch (e) {
console.error('Error loading shared conversation:', e);
error(404, 'Conversation not found or not shared publicly');
}
};

View file

@ -0,0 +1,118 @@
<script lang="ts">
import * as Icons from '$lib/components/icons';
import { Button } from '$lib/components/ui/button';
import { LightSwitch } from '$lib/components/ui/light-switch/index.js';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { type Doc } from '$lib/backend/convex/_generated/dataModel.js';
import Message from '../../chat/[id]/message.svelte';
let { data }: {
data: {
conversation: Doc<'conversations'>;
messages: Doc<'messages'>[];
}
} = $props();
const formatDate = (timestamp: number | undefined) => {
if (!timestamp) return '';
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
</script>
<svelte:head>
<title>{data.conversation.title} | Shared Chat</title>
<meta name="description" content="A shared conversation from Thom.chat" />
</svelte:head>
<div class="min-h-screen">
<!-- Header -->
<header class="border-border bg-background/95 sticky top-0 z-50 border-b backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto flex h-14 max-w-4xl items-center justify-between px-4">
<div class="flex items-center gap-4">
<a href="/" class="text-foreground hover:text-foreground/80 flex items-center gap-2 transition-colors">
<Icons.Svelte class="size-6" />
<span class="font-semibold">Thom.chat</span>
</a>
<div class="text-muted-foreground text-sm">
Shared conversation
</div>
</div>
<div class="flex items-center gap-2">
<Tooltip>
{#snippet trigger(tooltip)}
<Button
variant="ghost"
size="sm"
href="/chat"
{...tooltip.trigger}
>
Start your own chat
</Button>
{/snippet}
Create your own conversation
</Tooltip>
<LightSwitch variant="ghost" class="size-8" />
</div>
</div>
</header>
<!-- Main content -->
<main class="mx-auto max-w-4xl px-4 py-8">
<div class="space-y-6">
<!-- Conversation header -->
<div class="border-border rounded-lg border p-6">
<h1 class="text-foreground mb-2 text-2xl font-bold">{data.conversation.title}</h1>
<div class="text-muted-foreground flex items-center gap-4 text-sm">
{#if data.conversation.updated_at}
<span>Updated {formatDate(data.conversation.updated_at)}</span>
{/if}
<span>Public conversation</span>
</div>
</div>
<!-- Messages -->
{#if data.messages && data.messages.length > 0}
<div class="space-y-4">
{#each data.messages as message (message._id)}
<div class="border-border rounded-lg border p-4">
<Message {message} />
</div>
{/each}
</div>
{:else}
<div class="text-muted-foreground flex flex-col items-center justify-center py-12 text-center">
<p class="mb-2 text-lg">No messages in this conversation yet.</p>
<p class="text-sm">The conversation appears to be empty.</p>
</div>
{/if}
</div>
</main>
<!-- Footer -->
<footer class="border-border mt-16 border-t py-8">
<div class="mx-auto max-w-4xl px-4">
<div class="text-muted-foreground flex flex-col items-center gap-4 text-center text-sm sm:flex-row sm:justify-between">
<div class="flex items-center gap-4">
<a
href="https://github.com/TGlide/thom-chat"
class="hover:text-foreground flex items-center gap-1 transition-colors"
>
Source on <Icons.GitHub class="inline size-3" />
</a>
<span class="flex items-center gap-1">
Crafted by <Icons.Svelte class="inline size-3" /> wizards.
</span>
</div>
<div>
<a href="/chat" class="hover:text-foreground transition-colors">
Create your own conversation →
</a>
</div>
</div>
</div>
</footer>
</div>