almost there

This commit is contained in:
padreug 2025-02-11 15:32:12 +01:00
parent c7aa88bec6
commit b230e22ed1
2 changed files with 408 additions and 82 deletions

View file

@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Send, AlertCircle } from 'lucide-vue-next' import { Send, AlertCircle } from 'lucide-vue-next'
import MessageBubble from '@/components/ui/message-bubble/MessageBubble.vue'
const nostrStore = useNostrStore() const nostrStore = useNostrStore()
const input = ref('') const input = ref('')
@ -121,90 +122,104 @@ const formatDate = (timestamp: number) => {
} }
return date.toLocaleDateString() return date.toLocaleDateString()
} }
const getMessageGroupClasses = (sent: boolean) => {
return [
'group flex flex-col gap-0.5 animate-in slide-in-from-bottom-2',
sent ? 'items-end' : 'items-start'
]
}
</script> </script>
<template> <template>
<Card class="flex flex-col h-[calc(100vh-8rem)] bg-card"> <Card
<!-- Header --> class="flex flex-col h-[calc(100vh-2rem)] bg-gradient-to-b from-[#1e1e2e] to-[#181825] border-[#313244] shadow-2xl overflow-hidden relative z-0">
<CardHeader class="flex-shrink-0 flex flex-row items-center space-x-4 px-6 py-4"> <CardHeader
<div class="flex items-center space-x-4"> class="flex-shrink-0 flex flex-row items-center justify-between px-6 py-4 border-b border-[#313244]/50 bg-[#181825]/95 backdrop-blur-md relative z-50">
<Avatar class="h-10 w-10"> <!-- Left side with avatar and name -->
<AvatarFallback class="bg-primary/10 text-primary">SA</AvatarFallback> <div class="flex items-center gap-5 flex-shrink-0">
</Avatar> <div class="relative group">
<div> <div
<h3 class="font-semibold leading-none">Support Agent</h3> class="absolute -inset-0.5 bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] rounded-full opacity-75 group-hover:opacity-100 blur transition duration-200">
<p class="text-sm text-muted-foreground mt-1">Here to help you</p> </div>
<Avatar
class="relative h-11 w-11 bg-[#313244] ring-2 ring-[#cba6f7] ring-offset-2 ring-offset-[#181825] shadow-md transition-all duration-200 hover:shadow-lg group-hover:scale-105">
<AvatarFallback class="text-base font-semibold text-[#cdd6f4]">SA</AvatarFallback>
</Avatar>
</div>
<div class="hidden sm:block">
<p class="font-semibold leading-none text-[#cdd6f4] tracking-tight">Support Agent</p>
<div class="flex items-center gap-1.5 mt-1.5">
<div class="w-2 h-2 rounded-full bg-[#a6e3a1] animate-pulse"></div>
<p class="text-sm text-[#a6adc8]">Online</p>
</div>
</div>
</div>
<!-- Center badge -->
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div
class="text-xs font-medium text-[#cdd6f4] bg-gradient-to-r from-[#313244]/80 to-[#45475a]/80 backdrop-blur-sm px-4 py-2 rounded-full whitespace-nowrap shadow-lg border border-[#45475a]/50">
Customer Support
</div>
</div>
<!-- Right side spacer to maintain layout balance -->
<div class="flex items-center gap-5 flex-shrink-0 invisible">
<div class="h-11 w-11"></div>
<div class="hidden sm:block">
<div class="h-5"></div>
<div class="h-5 mt-1.5"></div>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<!-- Chat Area --> <CardContent class="flex-1 min-h-0 p-0 bg-gradient-to-b from-[#1e1e2e] to-[#181825] overflow-hidden">
<CardContent class="flex-1 p-0 overflow-hidden">
<ScrollArea class="h-full" type="hover"> <ScrollArea class="h-full" type="hover">
<div class="p-6"> <div class="flex flex-col gap-4 p-6">
<!-- Error Message --> <template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
<div v-if="error" class="mb-4 p-4 rounded-lg bg-destructive/10 text-destructive flex items-center space-x-2"> <!-- Date separator -->
<AlertCircle class="h-4 w-4" /> <div v-if="groupIndex === 0 ||
<span>{{ error }}</span> formatDate(group.timestamp) !== formatDate(groupedMessages[groupIndex - 1].timestamp)"
</div> class="flex justify-center my-8">
<div
<!-- Chat Messages --> class="px-4 py-1.5 rounded-full bg-gradient-to-r from-[#313244]/30 to-[#45475a]/30 text-xs font-medium text-[#a6adc8] shadow-lg backdrop-blur-sm border border-[#45475a]/20">
<div class="flex flex-col space-y-4"> {{ formatDate(group.timestamp) }}
<template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
<!-- Date separator -->
<div v-if="groupIndex === 0 || formatDate(group.timestamp) !== formatDate(groupedMessages[groupIndex - 1].timestamp)"
class="flex justify-center my-4">
<div class="px-3 py-1 rounded-full bg-muted text-xs text-muted-foreground">
{{ formatDate(group.timestamp) }}
</div>
</div> </div>
</div>
<!-- Message group --> <!-- Message group -->
<div :class="[ <div :class="getMessageGroupClasses(group.sent)" class="w-full">
'flex flex-col gap-2 animate-in fade-in-50 slide-in-from-bottom-5', <div class="flex flex-col gap-[3px] w-full" :class="{ 'items-end': group.sent }">
group.sent ? 'items-end' : 'items-start' <MessageBubble
]"> v-for="(message, messageIndex) in group.messages"
<div v-for="(message, messageIndex) in group.messages" :key="message.id" :key="message.id"
class="group flex flex-col space-y-0.5"> :sent="group.sent"
<div :class="[ :is-first="messageIndex === 0"
'px-3 py-2 rounded-2xl max-w-[85%] break-words text-sm', :is-last="messageIndex === group.messages.length - 1"
group.sent :content="message.content"
? 'bg-primary text-primary-foreground' :timestamp="message.created_at"
: 'bg-muted', :show-timestamp="messageIndex === group.messages.length - 1"
// Rounded corners based on position in group />
messageIndex === 0 && (group.sent ? 'rounded-tr-sm' : 'rounded-tl-sm'),
messageIndex === group.messages.length - 1 && (group.sent ? 'rounded-br-sm' : 'rounded-bl-sm')
]">
{{ message.content }}
</div>
<span class="px-2 text-[10px] text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
{{ formatTime(message.created_at) }}
</span>
</div>
</div> </div>
</template> </div>
<div ref="messagesEndRef" /> </template>
</div> <div ref="messagesEndRef" />
</div> </div>
</ScrollArea> </ScrollArea>
</CardContent> </CardContent>
<!-- Input Area --> <CardFooter
<CardFooter class="flex-shrink-0 p-4"> class="flex-shrink-0 mt-auto border-t border-[#313244]/50 bg-[#181825]/95 backdrop-blur-md p-6 shadow-xl">
<form @submit="sendMessage" class="flex w-full space-x-2"> <form @submit="sendMessage" class="flex w-full items-center gap-4">
<Input <Input id="message" v-model="input" placeholder="Type your message..."
v-model="input" class="flex-1 bg-[#1e1e2e]/90 border-[#313244] text-[#cdd6f4] placeholder:text-[#6c7086] focus:ring-2 focus:ring-[#cba6f7] focus:border-[#cba6f7] transition-all duration-300 shadow-lg hover:border-[#45475a] rounded-xl h-11"
type="text" autocomplete="off" />
placeholder="Type your message..." <Button type="submit" size="icon" :disabled="inputLength === 0 || isSending"
:disabled="!!error || isSending" class="bg-gradient-to-r from-[#cba6f7] to-[#89b4fa] text-[#11111b] hover:brightness-110 active:brightness-90 transition-all duration-300 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:hover:shadow-lg h-11 w-11 rounded-xl flex-shrink-0">
class="flex-1" <Send v-if="!isSending" class="h-4 w-4" />
/> <div v-else class="h-4 w-4 animate-spin rounded-full border-2 border-[#11111b] border-r-transparent" />
<Button <span class="sr-only">{{ isSending ? 'Sending...' : 'Send' }}</span>
type="submit"
:disabled="inputLength === 0 || !!error || isSending"
class="w-10 h-10 p-0"
>
<Send class="h-4 w-4" />
</Button> </Button>
</form> </form>
</CardFooter> </CardFooter>
@ -213,26 +228,147 @@ const formatDate = (timestamp: number) => {
<style scoped> <style scoped>
.animate-in { .animate-in {
animation-duration: 0.2s; animation: animate-in 0.3s cubic-bezier(0.21, 1.02, 0.73, 1);
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
animation-fill-mode: forwards;
} }
.fade-in-50 { @keyframes animate-in {
animation-name: fade-in-50; from {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
} }
.slide-in-from-bottom-5 { .break-words {
animation-name: slide-in-from-bottom-5; word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
} }
@keyframes fade-in-50 { .select-none {
from { opacity: 0; } user-select: none;
to { opacity: 1; } -webkit-user-select: none;
} }
@keyframes slide-in-from-bottom-5 { .select-text {
from { transform: translateY(5px); } user-select: text;
to { transform: translateY(0); } -webkit-user-select: text;
}
/* Enhanced scrollbar styling */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #45475a;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #585b70;
}
:deep(.scrollarea-viewport) {
height: 100%;
scroll-behavior: smooth;
}
:deep(.scrollarea-thumb-y) {
z-index: 30;
width: 5px !important;
background: #45475a !important;
border-radius: 9999px !important;
transition: all 0.2s ease-in-out;
}
:deep(.scrollarea-thumb-y:hover) {
background: #585b70 !important;
width: 6px !important;
}
/* Improved focus styles */
:focus-visible {
outline: 2px solid #cba6f7;
outline-offset: 2px;
transition: outline-offset 0.2s ease;
}
/* Enhanced button hover states */
button:not(:disabled):hover,
a:hover {
transform: translateY(-1px) scale(1.02);
}
button:not(:disabled):active,
a:active {
transform: translateY(0) scale(0.98);
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
/* Glass morphism effects */
.backdrop-blur-md {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Gradient animations */
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.bg-gradient-animate {
background-size: 200% 200%;
animation: gradient-shift 15s ease infinite;
}
/* Add these new styles */
.translate-center {
transform: translate(-50%, -50%);
}
/* Update flex layout styles */
.chat-card {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
max-height: calc(100vh - 2rem);
}
.chat-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.scrollarea-viewport {
height: 100%;
} }
</style> </style>

View file

@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'
import { ExternalLink, Copy, Check } from 'lucide-vue-next'
interface Props {
sent: boolean
isFirst: boolean
isLast: boolean
content: string
timestamp: number
showTimestamp?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showTimestamp: true
})
const copiedValues = ref(new Set<string>())
const bubbleClasses = computed(() => {
return cn(
'relative flex flex-col break-words min-w-[120px] max-w-[90%] md:max-w-[75%] text-sm',
props.sent
? 'bg-gradient-to-br from-purple-300 to-purple-400 text-gray-900'
: 'bg-gradient-to-br from-gray-700 to-gray-800 text-gray-100',
// First message in group
props.isFirst && (props.sent ? 'rounded-t-xl rounded-l-xl' : 'rounded-t-xl rounded-r-xl'),
// Last message in group
props.isLast && (props.sent ? 'rounded-b-xl rounded-l-xl' : 'rounded-b-xl rounded-r-xl'),
// Single message
props.isFirst && props.isLast && 'rounded-xl',
// Middle messages
!props.isFirst && !props.isLast && (props.sent ? 'rounded-l-xl' : 'rounded-r-xl')
)
})
const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
}
// Regular expressions for detecting special content
const BITCOIN_ADDRESS_REGEX = /\b([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[ac-hj-np-zAC-HJ-NP-Z02-9]{11,71})\b/g
const TXID_REGEX = /\b[a-fA-F0-9]{64}\b/g
const LIGHTNING_INVOICE_REGEX = /lnbc[a-zA-Z0-9]+/g
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
copiedValues.value.add(text)
setTimeout(() => {
copiedValues.value.delete(text)
}, 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const formatSpecialContent = (type: 'address' | 'txid' | 'invoice', value: string) => {
const isCopied = copiedValues.value.has(value)
const buttonClasses = 'inline-flex items-center gap-1 px-2 h-[22px] text-[11px] font-medium rounded bg-gray-800/20 hover:bg-gray-800/30 transition-colors'
if (type === 'txid') {
return `<div class="font-mono text-[11px] leading-normal break-all opacity-90">${value}</div><div class="flex flex-wrap items-center gap-1.5 mt-1.5"><button onclick="window.copyToClipboard('${value}')" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${isCopied ? '<path d="M20 6 9 17l-5-5"/>' : '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z"/>'}</svg>Copy txid</button><a href="https://mempool.space/tx/${value}" target="_blank" rel="noopener noreferrer" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10v10M7 17 17 7"/></svg>mempool.space</a></div>`
}
if (type === 'address') {
return `<div class="font-mono text-[11px] leading-normal break-all opacity-90">${value}</div><div class="flex flex-wrap items-center gap-1.5 mt-1.5"><button onclick="window.copyToClipboard('${value}')" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${isCopied ? '<path d="M20 6 9 17l-5-5"/>' : '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z"/>'}</svg>Copy wallet</button><a href="https://mempool.space/address/${value}" target="_blank" rel="noopener noreferrer" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10v10M7 17 17 7"/></svg>mempool.space</a></div>`
}
if (type === 'invoice') {
return `<div class="font-mono text-[11px] leading-normal break-all opacity-90">${value}</div><div class="flex flex-wrap items-center gap-1.5 mt-1.5"><button onclick="window.copyToClipboard('${value}')" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${isCopied ? '<path d="M20 6 9 17l-5-5"/>' : '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z"/>'}</svg>Copy invoice</button><a href="lightning:${value}" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m13 2-2 2.5h3L12 7"/><path d="M12 22v-3"/><path d="M12 17v-2"/><path d="M12 12V9"/></svg>Pay</a></div>`
}
return value
}
const formattedContent = computed(() => {
let processedText = props.content
// Check if the content is JSON
try {
const jsonContent = JSON.parse(processedText)
if (typeof jsonContent === 'object') {
return JSON.stringify(jsonContent, null, 2)
}
} catch {
// Not JSON, continue with normal processing
}
// Replace Bitcoin addresses with formatted content
processedText = processedText.replace(BITCOIN_ADDRESS_REGEX, (address) =>
formatSpecialContent('address', address)
)
// Replace transaction IDs with formatted content
processedText = processedText.replace(TXID_REGEX, (txid) =>
formatSpecialContent('txid', txid)
)
// Replace Lightning invoices with formatted content
processedText = processedText.replace(LIGHTNING_INVOICE_REGEX, (invoice) =>
formatSpecialContent('invoice', invoice)
)
return processedText
})
const isJson = computed(() => {
try {
JSON.parse(props.content)
return true
} catch {
return false
}
})
// Expose the copyToClipboard function to the window object for the onclick handlers
if (typeof window !== 'undefined') {
(window as any).copyToClipboard = copyToClipboard
}
</script>
<template>
<div :class="bubbleClasses">
<div class="px-3 py-2 break-words whitespace-pre-wrap">
<span v-if="isJson" class="font-mono text-[11px]">
<pre class="select-text p-2 rounded">{{ JSON.stringify(JSON.parse(content), null, 2) }}</pre>
</span>
<span v-else class="select-text text-[13px] leading-relaxed" v-html="formattedContent"></span>
</div>
<span v-if="showTimestamp && isLast" class="text-[10px] text-gray-500/80 px-3 pb-1 select-none"
:class="{ 'self-end': sent }">{{ formatTime(timestamp) }}</span>
</div>
</template>
<style scoped>
.break-words {
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.select-text {
user-select: text;
-webkit-user-select: text;
}
.select-none {
user-select: none;
-webkit-user-select: none;
}
:deep(a) {
transition: all 0.2s ease-in-out;
}
:deep(pre) {
background: rgba(0, 0, 0, 0.1);
margin: 0;
font-size: 11px;
line-height: 1.4;
}
:deep(button) {
cursor: pointer;
outline: none;
}
:deep(button:focus-visible),
:deep(a:focus-visible) {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Dark theme adjustments for JSON */
:deep(pre) {
background: rgba(0, 0, 0, 0.2);
}
/* Special content container styles */
:deep(.special-content) {
margin: 0.5rem 0;
border-radius: 0.5rem;
overflow: hidden;
}
</style>