better share modal
This commit is contained in:
parent
cbf387e07c
commit
16f689a598
9 changed files with 360 additions and 84 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
1
src/lib/components/ui/share-button/index.ts
Normal file
1
src/lib/components/ui/share-button/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as ShareButton } from './share-button.svelte';
|
||||
145
src/lib/components/ui/share-button/share-button.svelte
Normal file
145
src/lib/components/ui/share-button/share-button.svelte
Normal 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>
|
||||
|
||||
|
|
@ -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
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
32
src/routes/share/[id]/+page.server.ts
Normal file
32
src/routes/share/[id]/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
118
src/routes/share/[id]/+page.svelte
Normal file
118
src/routes/share/[id]/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue