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({
|
export const search = query({
|
||||||
args: {
|
args: {
|
||||||
session_token: v.string(),
|
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({
|
export const updateError = mutation({
|
||||||
args: {
|
args: {
|
||||||
session_token: v.string(),
|
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
|
<button
|
||||||
{...toggle.trigger}
|
{...toggle.trigger}
|
||||||
class={cn(
|
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 },
|
{ 'bg-primary': toggle.value },
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,15 @@
|
||||||
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Search } from '$lib/components/ui/search';
|
import { Search } from '$lib/components/ui/search';
|
||||||
|
import { models } from '$lib/state/models.svelte';
|
||||||
import { session } from '$lib/state/session.svelte';
|
import { session } from '$lib/state/session.svelte';
|
||||||
import { Provider } from '$lib/types.js';
|
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 { 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, {
|
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
||||||
provider: Provider.OpenRouter,
|
provider: Provider.OpenRouter,
|
||||||
|
|
@ -30,14 +30,26 @@
|
||||||
disabled: true,
|
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(
|
const openRouterModels = $derived(
|
||||||
fuzzysearch({
|
fuzzysearch({
|
||||||
haystack: models.from(Provider.OpenRouter),
|
haystack: models.from(Provider.OpenRouter),
|
||||||
needle: search,
|
needle: search,
|
||||||
property: 'name',
|
property: 'name',
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
if (a.enabled && !b.enabled) return -1;
|
const aEnabled = initiallyEnabled.includes(a.id);
|
||||||
if (!a.enabled && b.enabled) return 1;
|
const bEnabled = initiallyEnabled.includes(b.id);
|
||||||
|
if (aEnabled && !bEnabled) return -1;
|
||||||
|
if (!aEnabled && bEnabled) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ImageModal } from '$lib/components/ui/image-modal';
|
import { ImageModal } from '$lib/components/ui/image-modal';
|
||||||
import { LightSwitch } from '$lib/components/ui/light-switch/index.js';
|
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 * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
|
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 { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
|
||||||
import { models } from '$lib/state/models.svelte';
|
import { models } from '$lib/state/models.svelte';
|
||||||
import { usePrompt } from '$lib/state/prompt.svelte.js';
|
import { usePrompt } from '$lib/state/prompt.svelte.js';
|
||||||
|
|
@ -26,17 +26,14 @@
|
||||||
import { cn } from '$lib/utils/utils.js';
|
import { cn } from '$lib/utils/utils.js';
|
||||||
import { useConvexClient } from 'convex-svelte';
|
import { useConvexClient } from 'convex-svelte';
|
||||||
import { FileUpload, Popover } from 'melt/builders';
|
import { FileUpload, Popover } from 'melt/builders';
|
||||||
import { ResultAsync } from 'neverthrow';
|
|
||||||
import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed';
|
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 SendIcon from '~icons/lucide/arrow-up';
|
||||||
import CheckIcon from '~icons/lucide/check';
|
|
||||||
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
||||||
import ImageIcon from '~icons/lucide/image';
|
import ImageIcon from '~icons/lucide/image';
|
||||||
import PanelLeftIcon from '~icons/lucide/panel-left';
|
import PanelLeftIcon from '~icons/lucide/panel-left';
|
||||||
import SearchIcon from '~icons/lucide/search';
|
import SearchIcon from '~icons/lucide/search';
|
||||||
import Settings2Icon from '~icons/lucide/settings-2';
|
import Settings2Icon from '~icons/lucide/settings-2';
|
||||||
import ShareIcon from '~icons/lucide/share';
|
|
||||||
import StopIcon from '~icons/lucide/square';
|
import StopIcon from '~icons/lucide/square';
|
||||||
import UploadIcon from '~icons/lucide/upload';
|
import UploadIcon from '~icons/lucide/upload';
|
||||||
import XIcon from '~icons/lucide/x';
|
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);
|
const textareaSize = new ElementSize(() => textarea);
|
||||||
|
|
||||||
let textareaWrapper = $state<HTMLDivElement>();
|
let textareaWrapper = $state<HTMLDivElement>();
|
||||||
|
|
@ -473,32 +426,10 @@
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{#if page.params.id && currentConversationQuery.data}
|
{#if page.params.id && currentConversationQuery.data}
|
||||||
<Tooltip>
|
<ShareButton
|
||||||
{#snippet trigger(tooltip)}
|
conversationId={page.params.id as Id<'conversations'>}
|
||||||
<Button
|
isPublic={currentConversationQuery.data.public}
|
||||||
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}
|
{/if}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
{#snippet trigger(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