shareable chats (#23)
This commit is contained in:
parent
b1005d7df5
commit
16ff595926
5 changed files with 114 additions and 6 deletions
|
|
@ -62,7 +62,7 @@ TODO: add instructions
|
|||
- [ ] Chat branching
|
||||
- [ ] Regenerate message
|
||||
- ~[ ] Image generation~
|
||||
- [ ] Chat sharing
|
||||
- [x] Chat sharing
|
||||
- [ ] 404 page/redirect
|
||||
- ~[ ] Test link with free credits~
|
||||
- [x] Cursor-like Rules (@ieedan's idea!)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const getById = query({
|
|||
|
||||
const conversation = await ctx.db.get(args.conversation_id);
|
||||
|
||||
if (!conversation || conversation.user_id !== session.userId) {
|
||||
if (!conversation || (conversation.user_id !== session.userId && !conversation.public)) {
|
||||
throw new Error('Conversation not found or unauthorized');
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +80,7 @@ export const create = mutation({
|
|||
user_id: session.userId as any,
|
||||
updated_at: Date.now(),
|
||||
generating: true,
|
||||
public: false,
|
||||
});
|
||||
|
||||
return res;
|
||||
|
|
@ -124,6 +125,7 @@ export const createAndAddMessage = mutation({
|
|||
user_id: session.userId as any,
|
||||
updated_at: Date.now(),
|
||||
generating: true,
|
||||
public: false,
|
||||
});
|
||||
|
||||
const messageId = await ctx.runMutation(api.messages.create, {
|
||||
|
|
@ -210,9 +212,7 @@ export const updateCostUsd = mutation({
|
|||
session_token: args.session_token,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!session) throw new Error('Unauthorized');
|
||||
|
||||
// Verify the conversation belongs to the user
|
||||
const conversation = await ctx.db.get(args.conversation_id);
|
||||
|
|
@ -226,6 +226,29 @@ export const updateCostUsd = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export const makePublic = mutation({
|
||||
args: {
|
||||
conversation_id: v.id('conversations'),
|
||||
session_token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||
session_token: args.session_token,
|
||||
});
|
||||
|
||||
if (!session) throw new Error('Unauthorized');
|
||||
|
||||
const conversation = await ctx.db.get(args.conversation_id);
|
||||
if (!conversation || conversation.user_id !== session.userId) {
|
||||
throw new Error('Conversation not found or unauthorized');
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.conversation_id, {
|
||||
public: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const togglePin = mutation({
|
||||
args: {
|
||||
conversation_id: v.id('conversations'),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export default defineSchema({
|
|||
pinned: v.optional(v.boolean()),
|
||||
generating: v.optional(v.boolean()),
|
||||
cost_usd: v.optional(v.number()),
|
||||
public: v.boolean(),
|
||||
}).index('by_user', ['user_id']),
|
||||
messages: defineTable({
|
||||
conversation_id: v.string(),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
onCopy,
|
||||
onclick,
|
||||
class: className,
|
||||
tabindex = -1,
|
||||
children,
|
||||
|
|
@ -43,7 +44,9 @@
|
|||
class={cn('flex items-center gap-2', className)}
|
||||
type="button"
|
||||
name="copy"
|
||||
onclick={async () => {
|
||||
onclick={async (e) => {
|
||||
onclick?.(e);
|
||||
|
||||
const status = await clipboard.copy(text);
|
||||
|
||||
onCopy?.(status);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@
|
|||
import ModelPicker from './model-picker.svelte';
|
||||
import AppSidebar from '$lib/components/app-sidebar.svelte';
|
||||
import { cn } from '$lib/utils/utils.js';
|
||||
import ShareIcon from '~icons/lucide/share';
|
||||
import { Result, ResultAsync } from 'neverthrow';
|
||||
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte.js';
|
||||
import { scale } from 'svelte/transition';
|
||||
import CheckIcon from '~icons/lucide/check';
|
||||
import LockIcon from '~icons/lucide/lock';
|
||||
import LockOpenIcon from '~icons/lucide/lock-open';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
|
|
@ -330,6 +337,36 @@
|
|||
}
|
||||
}
|
||||
|
||||
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.makePublic, {
|
||||
conversation_id: page.params.id as Id<'conversations'>,
|
||||
session_token: session.current?.session.token ?? '',
|
||||
}),
|
||||
(e) => e
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
sharingStatus = 'failure';
|
||||
setTimeout(() => {
|
||||
sharingStatus = undefined;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
clipboard.copy(page.url.toString());
|
||||
}
|
||||
|
||||
const textareaSize = new ElementSize(() => textarea);
|
||||
|
||||
let textareaWrapper = $state<HTMLDivElement>();
|
||||
|
|
@ -368,8 +405,52 @@
|
|||
{cmdOrCtrl} + B
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div
|
||||
class="fixed top-3 left-10 z-50 flex size-9 items-center justify-center md:top-1 md:left-auto"
|
||||
{...tooltip.trigger}
|
||||
>
|
||||
{#if currentConversationQuery.data?.public}
|
||||
<LockOpenIcon class="size-4" />
|
||||
{:else}
|
||||
<LockIcon class="size-4" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{currentConversationQuery.data?.public ? 'Public' : 'Private'}
|
||||
</Tooltip>
|
||||
|
||||
<!-- header -->
|
||||
<div class="md:bg-sidebar fixed top-2 right-2 z-50 flex rounded-bl-lg p-1 md:top-0 md:right-0">
|
||||
{#if page.params.id}
|
||||
<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>
|
||||
{/if}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<Button variant="ghost" size="icon" class="size-8" href="/account" {...tooltip.trigger}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue