big milestone 2!!!
This commit is contained in:
parent
ac906ca6c9
commit
231658b980
8 changed files with 167 additions and 297 deletions
2
.env
Normal file
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Support agent's public key in npub format
|
||||||
|
VITE_SUPPORT_NPUB=npub1p9290wv4q4694nwtvgewpraardttt7cljduq2vdwyp0y6n5pczsszet4h7
|
||||||
|
|
@ -79,7 +79,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||||
*/
|
*/
|
||||||
workbox.precacheAndRoute([{
|
workbox.precacheAndRoute([{
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.0k43usmfr4"
|
"revision": "0.2675lqganp8"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|
|
||||||
|
|
@ -1,278 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
|
||||||
import { Send, Copy, ExternalLink, Check, AlertCircle } from 'lucide-vue-next'
|
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
|
||||||
import type { DirectMessage } from '@/types/nostr'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const nostrStore = useNostrStore()
|
|
||||||
const input = ref('')
|
|
||||||
const inputLength = computed(() => input.value.trim().length)
|
|
||||||
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
|
||||||
const isSending = ref(false)
|
|
||||||
|
|
||||||
// Group messages by sender and time
|
|
||||||
interface MessageGroup {
|
|
||||||
sent: boolean
|
|
||||||
messages: DirectMessage[]
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupedMessages = computed<MessageGroup[]>(() => {
|
|
||||||
const groups: MessageGroup[] = []
|
|
||||||
let currentGroup: MessageGroup | null = null
|
|
||||||
|
|
||||||
// Sort messages by timestamp first
|
|
||||||
const sortedMessages = [...nostrStore.currentMessages].sort((a, b) => a.created_at - b.created_at)
|
|
||||||
|
|
||||||
for (const message of sortedMessages) {
|
|
||||||
// Start a new group if:
|
|
||||||
// 1. No current group
|
|
||||||
// 2. Different sender than last message
|
|
||||||
// 3. More than 2 minutes since last message
|
|
||||||
if (!currentGroup ||
|
|
||||||
currentGroup.sent !== message.sent ||
|
|
||||||
message.created_at - currentGroup.messages[currentGroup.messages.length - 1].created_at > 120) {
|
|
||||||
currentGroup = {
|
|
||||||
sent: message.sent,
|
|
||||||
messages: [],
|
|
||||||
timestamp: message.created_at
|
|
||||||
}
|
|
||||||
groups.push(currentGroup)
|
|
||||||
}
|
|
||||||
currentGroup.messages.push(message)
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scroll to bottom when new messages arrive
|
|
||||||
watch(() => nostrStore.currentMessages.length, () => {
|
|
||||||
nextTick(() => {
|
|
||||||
scrollToBottom()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
scrollToBottom()
|
|
||||||
})
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
if (messagesEndRef.value) {
|
|
||||||
messagesEndRef.value.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendMessage = async (event: Event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
isSending.value = true
|
|
||||||
await nostrStore.sendMessage(nostrStore.activeChat, input.value)
|
|
||||||
input.value = ''
|
|
||||||
} finally {
|
|
||||||
isSending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTime = (timestamp: number) => {
|
|
||||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (timestamp: number) => {
|
|
||||||
const date = new Date(timestamp * 1000)
|
|
||||||
const today = new Date()
|
|
||||||
const yesterday = new Date(today)
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1)
|
|
||||||
|
|
||||||
if (date.toDateString() === today.toDateString()) {
|
|
||||||
return 'Today'
|
|
||||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
|
||||||
return 'Yesterday'
|
|
||||||
}
|
|
||||||
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'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMessageBubbleClasses = (sent: boolean, isFirst: boolean, isLast: boolean) => {
|
|
||||||
return [
|
|
||||||
'relative flex flex-col break-words min-w-[120px] max-w-[85%] md:max-w-[65%] text-sm',
|
|
||||||
sent
|
|
||||||
? 'bg-gradient-to-br from-[#cba6f7] to-[#b4befe] text-[#11111b] hover:brightness-105' // Gradient from Mauve to Lavender
|
|
||||||
: 'bg-gradient-to-br from-[#313244] to-[#45475a] text-[#cdd6f4] hover:brightness-105', // Gradient from Surface0 to Surface1
|
|
||||||
// First message in group
|
|
||||||
isFirst && (sent ? 'rounded-t-2xl rounded-l-2xl' : 'rounded-t-2xl rounded-r-2xl'),
|
|
||||||
// Last message in group
|
|
||||||
isLast && (sent ? 'rounded-b-2xl rounded-l-2xl' : 'rounded-b-2xl rounded-r-2xl'),
|
|
||||||
// Single message
|
|
||||||
isFirst && isLast && 'rounded-2xl',
|
|
||||||
// Middle messages
|
|
||||||
!isFirst && !isLast && (sent ? 'rounded-l-2xl' : 'rounded-r-2xl'),
|
|
||||||
// Add shadow and hover effect
|
|
||||||
'shadow-lg hover:shadow-xl transition-all duration-300 ease-out'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Card
|
|
||||||
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 justify-between px-6 py-4 border-b border-[#313244]/50 bg-[#181825]/95 backdrop-blur-md relative z-50">
|
|
||||||
<!-- Left side with avatar and name -->
|
|
||||||
<div class="flex items-center gap-5 flex-shrink-0">
|
|
||||||
<div class="relative group">
|
|
||||||
<div
|
|
||||||
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">
|
|
||||||
</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>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="flex-1 min-h-0 p-0 bg-gradient-to-b from-[#1e1e2e] to-[#181825] overflow-hidden">
|
|
||||||
<ScrollArea class="h-full" type="hover">
|
|
||||||
<div class="flex flex-col gap-4 p-6">
|
|
||||||
<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-8">
|
|
||||||
<div
|
|
||||||
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">
|
|
||||||
{{ formatDate(group.timestamp) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message group -->
|
|
||||||
<div :class="getMessageGroupClasses(group.sent)" class="w-full">
|
|
||||||
<div class="flex flex-col gap-[3px] w-full" :class="{ 'items-end': group.sent }">
|
|
||||||
<div v-for="(message, messageIndex) in group.messages" :key="message.id" :class="[
|
|
||||||
getMessageBubbleClasses(
|
|
||||||
group.sent,
|
|
||||||
messageIndex === 0,
|
|
||||||
messageIndex === group.messages.length - 1
|
|
||||||
),
|
|
||||||
'w-fit backdrop-blur-sm'
|
|
||||||
]">
|
|
||||||
<div class="p-3">
|
|
||||||
{{ message.content }}
|
|
||||||
</div>
|
|
||||||
<div class="px-3 pb-1.5 text-[10px] opacity-50">
|
|
||||||
{{ formatTime(message.created_at) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div ref="messagesEndRef" />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter class="flex-shrink-0 p-4 bg-[#181825]/95 backdrop-blur-md border-t border-[#313244]/50">
|
|
||||||
<form @submit="sendMessage" class="flex items-center gap-2 w-full">
|
|
||||||
<Input
|
|
||||||
v-model="input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type your message..."
|
|
||||||
class="flex-1 bg-[#1e1e2e] border-[#313244] text-[#cdd6f4] placeholder:text-[#6c7086] focus:ring-2 focus:ring-[#cba6f7] focus:border-[#cba6f7] transition-all duration-300"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:disabled="inputLength === 0 || 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"
|
|
||||||
>
|
|
||||||
<Send class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.animate-in {
|
|
||||||
animation: animate-in 0.5s cubic-bezier(0.21, 1.02, 0.73, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes animate-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px) scale(0.98);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 {
|
|
||||||
transform: translateY(-1px) scale(1.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:not(:disabled):active {
|
|
||||||
transform: translateY(0) scale(0.99);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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-sm {
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
import { useNostrStore } from '@/stores/nostr'
|
||||||
import { npubToHex } from '@/lib/nostr'
|
import { npubToHex } from '@/lib/nostr'
|
||||||
|
import type { DirectMessage } from '@/types/nostr'
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
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 ChatBox from './ChatBox.vue'
|
|
||||||
|
|
||||||
const nostrStore = useNostrStore()
|
const nostrStore = useNostrStore()
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
const isSending = ref(false)
|
const isSending = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
||||||
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
|
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
|
||||||
|
|
||||||
if (!SUPPORT_NPUB) {
|
if (!SUPPORT_NPUB) {
|
||||||
|
|
@ -22,6 +23,47 @@ if (!SUPPORT_NPUB) {
|
||||||
|
|
||||||
const inputLength = computed(() => input.value.trim().length)
|
const inputLength = computed(() => input.value.trim().length)
|
||||||
|
|
||||||
|
// Group messages by sender and time
|
||||||
|
interface MessageGroup {
|
||||||
|
sent: boolean
|
||||||
|
messages: DirectMessage[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedMessages = computed<MessageGroup[]>(() => {
|
||||||
|
const groups: MessageGroup[] = []
|
||||||
|
let currentGroup: MessageGroup | null = null
|
||||||
|
|
||||||
|
// Sort messages by timestamp first
|
||||||
|
const sortedMessages = [...nostrStore.currentMessages].sort((a, b) => a.created_at - b.created_at)
|
||||||
|
|
||||||
|
for (const message of sortedMessages) {
|
||||||
|
// Start a new group if:
|
||||||
|
// 1. No current group
|
||||||
|
// 2. Different sender than last message
|
||||||
|
// 3. More than 2 minutes since last message
|
||||||
|
if (!currentGroup ||
|
||||||
|
currentGroup.sent !== message.sent ||
|
||||||
|
message.created_at - currentGroup.messages[currentGroup.messages.length - 1].created_at > 120) {
|
||||||
|
currentGroup = {
|
||||||
|
sent: message.sent,
|
||||||
|
messages: [],
|
||||||
|
timestamp: message.created_at
|
||||||
|
}
|
||||||
|
groups.push(currentGroup)
|
||||||
|
}
|
||||||
|
currentGroup.messages.push(message)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scroll to bottom when new messages arrive
|
||||||
|
watch(() => nostrStore.currentMessages.length, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
if (!SUPPORT_NPUB) return
|
if (!SUPPORT_NPUB) return
|
||||||
|
|
@ -29,12 +71,19 @@ onMounted(async () => {
|
||||||
const supportPubkeyHex = npubToHex(SUPPORT_NPUB)
|
const supportPubkeyHex = npubToHex(SUPPORT_NPUB)
|
||||||
nostrStore.activeChat = supportPubkeyHex
|
nostrStore.activeChat = supportPubkeyHex
|
||||||
await nostrStore.subscribeToMessages()
|
await nostrStore.subscribeToMessages()
|
||||||
|
scrollToBottom()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to initialize support chat:', err)
|
console.error('Failed to initialize support chat:', err)
|
||||||
error.value = 'Failed to connect to support. Please try again later.'
|
error.value = 'Failed to connect to support. Please try again later.'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesEndRef.value) {
|
||||||
|
messagesEndRef.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendMessage = async (event: Event) => {
|
const sendMessage = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
|
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
|
||||||
|
|
@ -51,6 +100,27 @@ const sendMessage = async (event: Event) => {
|
||||||
isSending.value = false
|
isSending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const today = new Date()
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Today'
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Yesterday'
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -79,7 +149,42 @@ const sendMessage = async (event: Event) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat Messages -->
|
<!-- Chat Messages -->
|
||||||
<ChatBox />
|
<div class="flex flex-col space-y-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Message group -->
|
||||||
|
<div :class="[
|
||||||
|
'flex flex-col gap-2 animate-in fade-in-50 slide-in-from-bottom-5',
|
||||||
|
group.sent ? 'items-end' : 'items-start'
|
||||||
|
]">
|
||||||
|
<div v-for="(message, messageIndex) in group.messages" :key="message.id"
|
||||||
|
class="group flex flex-col space-y-0.5">
|
||||||
|
<div :class="[
|
||||||
|
'px-3 py-2 rounded-2xl max-w-[85%] break-words text-sm',
|
||||||
|
group.sent
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted',
|
||||||
|
// 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>
|
||||||
|
</template>
|
||||||
|
<div ref="messagesEndRef" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -108,17 +213,26 @@ const sendMessage = async (event: Event) => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.animate-in {
|
.animate-in {
|
||||||
animation: animate-in 0.2s ease-out;
|
animation-duration: 0.2s;
|
||||||
|
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
animation-fill-mode: forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes animate-in {
|
.fade-in-50 {
|
||||||
from {
|
animation-name: fade-in-50;
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
}
|
||||||
to {
|
|
||||||
opacity: 1;
|
.slide-in-from-bottom-5 {
|
||||||
transform: translateY(0);
|
animation-name: slide-in-from-bottom-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-50 {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-from-bottom-5 {
|
||||||
|
from { transform: translateY(5px); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Menu, X, Sun, Moon, Zap, MessageSquareText } from 'lucide-vue-next'
|
import { Menu, X, Sun, Moon, Zap, MessageSquareText, LogIn, LogOut } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
import { useTheme } from '@/components/theme-provider'
|
||||||
|
import { useNostrStore } from '@/stores/nostr'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const nostrStore = useNostrStore()
|
||||||
|
const router = useRouter()
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
|
||||||
const navigation = computed(() => [
|
const navigation = computed(() => [
|
||||||
|
|
@ -31,6 +35,14 @@ const toggleLocale = () => {
|
||||||
// Store the preference
|
// Store the preference
|
||||||
localStorage.setItem('locale', newLocale)
|
localStorage.setItem('locale', newLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAuth = () => {
|
||||||
|
if (nostrStore.isLoggedIn) {
|
||||||
|
nostrStore.logout()
|
||||||
|
} else {
|
||||||
|
router.push('/support')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -55,7 +67,7 @@ const toggleLocale = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Toggle and Language -->
|
<!-- Theme Toggle, Language, and Auth -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" @click="toggleTheme">
|
<Button variant="ghost" size="icon" @click="toggleTheme">
|
||||||
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
||||||
|
|
@ -67,6 +79,11 @@ const toggleLocale = () => {
|
||||||
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" @click="handleAuth" class="text-muted-foreground hover:text-foreground">
|
||||||
|
<LogIn v-if="!nostrStore.isLoggedIn" class="h-5 w-5" />
|
||||||
|
<LogOut v-else class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<Button variant="ghost" size="icon" class="md:hidden" @click="toggleMenu">
|
<Button variant="ghost" size="icon" class="md:hidden" @click="toggleMenu">
|
||||||
<span class="sr-only">Open main menu</span>
|
<span class="sr-only">Open main menu</span>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ export default {
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
directory: 'Directory',
|
directory: 'Directory',
|
||||||
faq: 'FAQ',
|
faq: 'FAQ',
|
||||||
support: 'Support'
|
support: 'Support',
|
||||||
|
login: 'Login',
|
||||||
|
logout: 'Logout'
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
title: 'Find Bitcoin Lightning Acceptors',
|
title: 'Find Bitcoin Lightning Acceptors',
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ export default {
|
||||||
home: 'Inicio',
|
home: 'Inicio',
|
||||||
directory: 'Directorio',
|
directory: 'Directorio',
|
||||||
faq: 'Preguntas',
|
faq: 'Preguntas',
|
||||||
support: 'Soporte'
|
support: 'Soporte',
|
||||||
|
login: 'Iniciar Sesión',
|
||||||
|
logout: 'Cerrar Sesión'
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
title: 'Encuentra Aceptadores de Bitcoin Lightning',
|
title: 'Encuentra Aceptadores de Bitcoin Lightning',
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,22 @@ const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
async function connectToRelay(url: string) {
|
async function connectToRelay(url: string) {
|
||||||
|
console.log(`Attempting to connect to relay: ${url}`)
|
||||||
const relay = window.NostrTools.relayInit(url)
|
const relay = window.NostrTools.relayInit(url)
|
||||||
try {
|
try {
|
||||||
|
console.log(`Initializing connection to ${url}...`)
|
||||||
await relay.connect()
|
await relay.connect()
|
||||||
|
console.log(`Successfully connected to ${url}`)
|
||||||
return relay
|
return relay
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to connect to ${url}:`, err)
|
console.error(`Failed to connect to ${url}:`, err)
|
||||||
|
if (err instanceof Error) {
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: err.message,
|
||||||
|
name: err.name,
|
||||||
|
stack: err.stack
|
||||||
|
})
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -320,6 +330,7 @@ export const useNostrStore = defineStore('nostr', () => {
|
||||||
init,
|
init,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
sendMessage
|
sendMessage,
|
||||||
|
subscribeToMessages
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue