bare repo
This commit is contained in:
parent
d73f9bc01e
commit
3d356225cd
31 changed files with 134 additions and 3005 deletions
10
src/App.vue
10
src/App.vue
|
|
@ -1,11 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import Navbar from '@/components/layout/Navbar.vue'
|
import Navbar from '@/components/layout/Navbar.vue'
|
||||||
import Footer from '@/components/layout/Footer.vue'
|
import Footer from '@/components/layout/Footer.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const showFooter = computed(() => route.path !== '/support')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -23,11 +19,7 @@ const showFooter = computed(() => route.path !== '/support')
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer v-if="showFooter" />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Remove default styles */
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -3,65 +3,105 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 227 92% 95%;
|
/* Light theme */
|
||||||
--foreground: 234 16% 35%;
|
--background: rgb(231, 236, 254);
|
||||||
|
--background: oklch(0.97 0.02 250);
|
||||||
|
--foreground: rgb(75, 78, 104);
|
||||||
|
--foreground: oklch(0.40 0.03 265);
|
||||||
|
|
||||||
--card: 225 23% 92%;
|
--card: rgb(226, 230, 242);
|
||||||
--card-foreground: 234 16% 35%;
|
--card: oklch(0.92 0.02 260);
|
||||||
|
--card-foreground: rgb(75, 78, 104);
|
||||||
|
--card-foreground: oklch(0.40 0.03 265);
|
||||||
|
|
||||||
--popover: 225 23% 92%;
|
--popover: rgb(226, 230, 242);
|
||||||
--popover-foreground: 234 16% 35%;
|
--popover: oklch(0.92 0.02 260);
|
||||||
|
--popover-foreground: rgb(75, 78, 104);
|
||||||
|
--popover-foreground: oklch(0.40 0.03 265);
|
||||||
|
|
||||||
--primary: 220 91% 54%;
|
--primary: rgb(31, 102, 244);
|
||||||
--primary-foreground: 227 92% 95%;
|
--primary: oklch(0.60 0.20 260);
|
||||||
|
--primary-foreground: rgb(231, 236, 254);
|
||||||
|
--primary-foreground: oklch(0.97 0.02 250);
|
||||||
|
|
||||||
--secondary: 227 23% 83%;
|
--secondary: rgb(212, 217, 228);
|
||||||
--secondary-foreground: 234 16% 35%;
|
--secondary: oklch(0.87 0.03 255);
|
||||||
|
--secondary-foreground: rgb(75, 78, 104);
|
||||||
|
--secondary-foreground: oklch(0.40 0.03 265);
|
||||||
|
|
||||||
--muted: 227 23% 83%;
|
--muted: rgb(212, 217, 228);
|
||||||
--muted-foreground: 231 11% 47%;
|
--muted: oklch(0.87 0.03 255);
|
||||||
|
--muted-foreground: rgb(115, 120, 141);
|
||||||
|
--muted-foreground: oklch(0.55 0.03 265);
|
||||||
|
|
||||||
--accent: 11 83% 67%;
|
--accent: rgb(241, 127, 101);
|
||||||
--accent-foreground: 227 92% 95%;
|
--accent: oklch(0.70 0.15 30);
|
||||||
|
--accent-foreground: rgb(231, 236, 254);
|
||||||
|
--accent-foreground: oklch(0.97 0.02 250);
|
||||||
|
|
||||||
--destructive: 347 87% 44%;
|
--destructive: rgb(210, 15, 57);
|
||||||
--destructive-foreground: 227 92% 95%;
|
--destructive: oklch(0.50 0.28 15);
|
||||||
|
--destructive-foreground: rgb(231, 236, 254);
|
||||||
|
--destructive-foreground: oklch(0.97 0.02 250);
|
||||||
|
|
||||||
--border: 228 17% 77%;
|
--border: rgb(197, 201, 216);
|
||||||
--input: 228 17% 77%;
|
--border: oklch(0.83 0.02 265);
|
||||||
--ring: 220 91% 54%;
|
--input: rgb(197, 201, 216);
|
||||||
|
--input: oklch(0.83 0.02 265);
|
||||||
|
--ring: rgb(31, 102, 244);
|
||||||
|
--ring: oklch(0.60 0.20 260);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 233 31% 18%;
|
/* Dark theme */
|
||||||
--foreground: 227 68% 88%;
|
--background: rgb(26, 32, 54);
|
||||||
|
--background: oklch(0.25 0.05 265);
|
||||||
|
--foreground: rgb(218, 226, 248);
|
||||||
|
--foreground: oklch(0.90 0.03 260);
|
||||||
|
|
||||||
--card: 234 32% 15%;
|
--card: rgb(22, 27, 45);
|
||||||
--card-foreground: 227 68% 88%;
|
--card: oklch(0.20 0.05 265);
|
||||||
|
--card-foreground: rgb(218, 226, 248);
|
||||||
|
--card-foreground: oklch(0.90 0.03 260);
|
||||||
|
|
||||||
--popover: 234 32% 15%;
|
--popover: rgb(22, 27, 45);
|
||||||
--popover-foreground: 227 68% 88%;
|
--popover: oklch(0.20 0.05 265);
|
||||||
|
--popover-foreground: rgb(218, 226, 248);
|
||||||
|
--popover-foreground: oklch(0.90 0.03 260);
|
||||||
|
|
||||||
--primary: 220 83% 76%;
|
--primary: rgb(127, 167, 249);
|
||||||
--primary-foreground: 233 31% 18%;
|
--primary: oklch(0.75 0.15 260);
|
||||||
|
--primary-foreground: rgb(26, 32, 54);
|
||||||
|
--primary-foreground: oklch(0.25 0.05 265);
|
||||||
|
|
||||||
--secondary: 233 25% 26%;
|
--secondary: rgb(38, 46, 72);
|
||||||
--secondary-foreground: 227 68% 88%;
|
--secondary: oklch(0.30 0.06 265);
|
||||||
|
--secondary-foreground: rgb(218, 226, 248);
|
||||||
|
--secondary-foreground: oklch(0.90 0.03 260);
|
||||||
|
|
||||||
--muted: 233 25% 26%;
|
--muted: rgb(38, 46, 72);
|
||||||
--muted-foreground: 225 27% 72%;
|
--muted: oklch(0.30 0.06 265);
|
||||||
|
--muted-foreground: rgb(177, 186, 211);
|
||||||
|
--muted-foreground: oklch(0.78 0.06 265);
|
||||||
|
|
||||||
--accent: 11 77% 90%;
|
--accent: rgb(255, 179, 164);
|
||||||
--accent-foreground: 233 31% 18%;
|
--accent: oklch(0.83 0.12 30);
|
||||||
|
--accent-foreground: rgb(26, 32, 54);
|
||||||
|
--accent-foreground: oklch(0.25 0.05 265);
|
||||||
|
|
||||||
--destructive: 351 74% 76%;
|
--destructive: rgb(247, 130, 150);
|
||||||
--destructive-foreground: 233 31% 18%;
|
--destructive: oklch(0.75 0.18 15);
|
||||||
|
--destructive-foreground: rgb(26, 32, 54);
|
||||||
|
--destructive-foreground: oklch(0.25 0.05 265);
|
||||||
|
|
||||||
--border: 233 25% 26%;
|
--border: rgb(38, 46, 72);
|
||||||
--input: 233 25% 26%;
|
--border: oklch(0.30 0.06 265);
|
||||||
--ring: 220 83% 76%;
|
--input: rgb(38, 46, 72);
|
||||||
|
--input: oklch(0.30 0.06 265);
|
||||||
|
--ring: rgb(127, 167, 249);
|
||||||
|
--ring: oklch(0.75 0.15 260);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,106 +118,106 @@
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.bg-background {
|
.bg-background {
|
||||||
background-color: hsl(var(--background) / var(--tw-bg-opacity, 1));
|
background-color: var(--background);
|
||||||
}
|
}
|
||||||
.bg-foreground {
|
.bg-foreground {
|
||||||
background-color: hsl(var(--foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--foreground);
|
||||||
}
|
}
|
||||||
.bg-card {
|
.bg-card {
|
||||||
background-color: hsl(var(--card) / var(--tw-bg-opacity, 1));
|
background-color: var(--card);
|
||||||
}
|
}
|
||||||
.bg-card-foreground {
|
.bg-card-foreground {
|
||||||
background-color: hsl(var(--card-foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--card-foreground);
|
||||||
}
|
}
|
||||||
.bg-popover {
|
.bg-popover {
|
||||||
background-color: hsl(var(--popover) / var(--tw-bg-opacity, 1));
|
background-color: var(--popover);
|
||||||
}
|
}
|
||||||
.bg-popover-foreground {
|
.bg-popover-foreground {
|
||||||
background-color: hsl(var(--popover-foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--popover-foreground);
|
||||||
}
|
}
|
||||||
.bg-primary {
|
.bg-primary {
|
||||||
background-color: hsl(var(--primary) / var(--tw-bg-opacity, 1));
|
background-color: var(--primary);
|
||||||
}
|
}
|
||||||
.bg-primary-foreground {
|
.bg-primary-foreground {
|
||||||
background-color: hsl(var(--primary-foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--primary-foreground);
|
||||||
}
|
}
|
||||||
.bg-secondary {
|
.bg-secondary {
|
||||||
background-color: hsl(var(--secondary) / var(--tw-bg-opacity, 1));
|
background-color: var(--secondary);
|
||||||
}
|
}
|
||||||
.bg-secondary-foreground {
|
.bg-secondary-foreground {
|
||||||
background-color: hsl(var(--secondary-foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--secondary-foreground);
|
||||||
}
|
}
|
||||||
.bg-muted {
|
.bg-muted {
|
||||||
background-color: hsl(var(--muted) / var(--tw-bg-opacity, 1));
|
background-color: var(--muted);
|
||||||
}
|
}
|
||||||
.bg-muted-foreground {
|
.bg-muted-foreground {
|
||||||
background-color: hsl(var(--muted-foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.bg-accent {
|
.bg-accent {
|
||||||
background-color: hsl(var(--accent) / var(--tw-bg-opacity, 1));
|
background-color: var(--accent);
|
||||||
}
|
}
|
||||||
.bg-accent-foreground {
|
.bg-accent-foreground {
|
||||||
background-color: hsl(var(--accent-foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--accent-foreground);
|
||||||
}
|
}
|
||||||
.bg-destructive {
|
.bg-destructive {
|
||||||
background-color: hsl(var(--destructive) / var(--tw-bg-opacity, 1));
|
background-color: var(--destructive);
|
||||||
}
|
}
|
||||||
.bg-destructive-foreground {
|
.bg-destructive-foreground {
|
||||||
background-color: hsl(var(--destructive-foreground) / var(--tw-bg-opacity, 1));
|
background-color: var(--destructive-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-background {
|
.text-background {
|
||||||
color: hsl(var(--background) / var(--tw-text-opacity, 1));
|
color: var(--background);
|
||||||
}
|
}
|
||||||
.text-foreground {
|
.text-foreground {
|
||||||
color: hsl(var(--foreground) / var(--tw-text-opacity, 1));
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.text-card {
|
.text-card {
|
||||||
color: hsl(var(--card) / var(--tw-text-opacity, 1));
|
color: var(--card);
|
||||||
}
|
}
|
||||||
.text-card-foreground {
|
.text-card-foreground {
|
||||||
color: hsl(var(--card-foreground) / var(--tw-text-opacity, 1));
|
color: var(--card-foreground);
|
||||||
}
|
}
|
||||||
.text-popover {
|
.text-popover {
|
||||||
color: hsl(var(--popover) / var(--tw-text-opacity, 1));
|
color: var(--popover);
|
||||||
}
|
}
|
||||||
.text-popover-foreground {
|
.text-popover-foreground {
|
||||||
color: hsl(var(--popover-foreground) / var(--tw-text-opacity, 1));
|
color: var(--popover-foreground);
|
||||||
}
|
}
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: hsl(var(--primary) / var(--tw-text-opacity, 1));
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
.text-primary-foreground {
|
.text-primary-foreground {
|
||||||
color: hsl(var(--primary-foreground) / var(--tw-text-opacity, 1));
|
color: var(--primary-foreground);
|
||||||
}
|
}
|
||||||
.text-secondary {
|
.text-secondary {
|
||||||
color: hsl(var(--secondary) / var(--tw-text-opacity, 1));
|
color: var(--secondary);
|
||||||
}
|
}
|
||||||
.text-secondary-foreground {
|
.text-secondary-foreground {
|
||||||
color: hsl(var(--secondary-foreground) / var(--tw-text-opacity, 1));
|
color: var(--secondary-foreground);
|
||||||
}
|
}
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: hsl(var(--muted) / var(--tw-text-opacity, 1));
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.text-muted-foreground {
|
.text-muted-foreground {
|
||||||
color: hsl(var(--muted-foreground) / var(--tw-text-opacity, 1));
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.text-accent {
|
.text-accent {
|
||||||
color: hsl(var(--accent) / var(--tw-text-opacity, 1));
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.text-accent-foreground {
|
.text-accent-foreground {
|
||||||
color: hsl(var(--accent-foreground) / var(--tw-text-opacity, 1));
|
color: var(--accent-foreground);
|
||||||
}
|
}
|
||||||
.text-destructive {
|
.text-destructive {
|
||||||
color: hsl(var(--destructive) / var(--tw-text-opacity, 1));
|
color: var(--destructive);
|
||||||
}
|
}
|
||||||
.text-destructive-foreground {
|
.text-destructive-foreground {
|
||||||
color: hsl(var(--destructive-foreground) / var(--tw-text-opacity, 1));
|
color: var(--destructive-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports not (backdrop-filter: blur(1px)) {
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
.select-content {
|
.select-content {
|
||||||
background-color: hsl(var(--background));
|
background-color: var(--background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -185,25 +225,25 @@
|
||||||
/* Add support for ring colors */
|
/* Add support for ring colors */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.ring-border {
|
.ring-border {
|
||||||
--tw-ring-color: hsl(var(--border) / var(--tw-ring-opacity, 1));
|
--tw-ring-color: var(--border);
|
||||||
}
|
}
|
||||||
.ring-primary {
|
.ring-primary {
|
||||||
--tw-ring-color: hsl(var(--primary) / var(--tw-ring-opacity, 1));
|
--tw-ring-color: var(--primary);
|
||||||
}
|
}
|
||||||
.ring-background {
|
.ring-background {
|
||||||
--tw-ring-color: hsl(var(--background) / var(--tw-ring-opacity, 1));
|
--tw-ring-color: var(--background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add support for border colors */
|
/* Add support for border colors */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.box-border {
|
.box-border {
|
||||||
border-color: hsl(var(--border) / var(--tw-border-opacity, 1));
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
.border-primary {
|
.border-primary {
|
||||||
border-color: hsl(var(--primary) / var(--tw-border-opacity, 1));
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
.border-background {
|
.border-background {
|
||||||
border-color: hsl(var(--background) / var(--tw-border-opacity, 1));
|
border-color: var(--background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,397 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, nextTick, onMounted, watch, onUnmounted } from 'vue'
|
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
|
||||||
import { npubToHex } from '@/lib/nostr'
|
|
||||||
import type { DirectMessage } from '@/types/nostr'
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
||||||
import { Send } from 'lucide-vue-next'
|
|
||||||
import MessageBubble from '@/components/ui/message-bubble/MessageBubble.vue'
|
|
||||||
import ConnectionStatus from '@/components/ConnectionStatus.vue'
|
|
||||||
|
|
||||||
const nostrStore = useNostrStore()
|
|
||||||
const input = ref('')
|
|
||||||
const isSending = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
|
||||||
const isSubscribing = ref(false)
|
|
||||||
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
|
|
||||||
|
|
||||||
if (!SUPPORT_NPUB) {
|
|
||||||
error.value = 'Support public key not configured'
|
|
||||||
}
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
try {
|
|
||||||
if (!SUPPORT_NPUB) return
|
|
||||||
|
|
||||||
const supportPubkeyHex = npubToHex(SUPPORT_NPUB)
|
|
||||||
nostrStore.activeChat = supportPubkeyHex
|
|
||||||
|
|
||||||
// Only subscribe if not already subscribed
|
|
||||||
if (!nostrStore.hasActiveSubscription) {
|
|
||||||
isSubscribing.value = true
|
|
||||||
nostrStore.subscribeToMessages()
|
|
||||||
.catch(err => {
|
|
||||||
console.debug('Support chat subscription error:', err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isSubscribing.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottom()
|
|
||||||
} catch (err) {
|
|
||||||
console.debug('Support chat setup error:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove the unsubscribe on unmount since we want to keep the connection
|
|
||||||
onUnmounted(() => {
|
|
||||||
// Only clear active chat
|
|
||||||
nostrStore.activeChat = null
|
|
||||||
})
|
|
||||||
|
|
||||||
// Watch for changes in activeChat
|
|
||||||
watch(() => nostrStore.activeChat, async (newChat) => {
|
|
||||||
if (newChat) {
|
|
||||||
try {
|
|
||||||
isSubscribing.value = true
|
|
||||||
await nostrStore.subscribeToMessages()
|
|
||||||
} catch (err) {
|
|
||||||
console.debug('Chat subscription error:', err)
|
|
||||||
// Continue anyway
|
|
||||||
} finally {
|
|
||||||
isSubscribing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
if (messagesEndRef.value) {
|
|
||||||
// Get the scroll area element
|
|
||||||
const scrollArea = messagesEndRef.value.closest('.scrollarea-viewport')
|
|
||||||
if (scrollArea) {
|
|
||||||
scrollArea.scrollTop = scrollArea.scrollHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = ''
|
|
||||||
error.value = ''
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to send message:', err)
|
|
||||||
error.value = 'Failed to send message. Please try again.'
|
|
||||||
} finally {
|
|
||||||
isSending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Card
|
|
||||||
class="flex flex-col h-full bg-gradient-to-b from-card to-background border-border 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-border/50 bg-background/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-primary to-primary/50 rounded-full opacity-75 group-hover:opacity-100 blur transition duration-200">
|
|
||||||
</div>
|
|
||||||
<Avatar
|
|
||||||
class="relative h-11 w-11 bg-muted ring-2 ring-ring ring-offset-2 ring-offset-background shadow-md transition-all duration-200 hover:shadow-lg group-hover:scale-105">
|
|
||||||
<AvatarFallback class="text-base font-semibold text-foreground">SA</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
<div class="hidden sm:block">
|
|
||||||
<p class="font-semibold leading-none text-foreground tracking-tight">Support Agent</p>
|
|
||||||
<div class="flex items-center gap-1.5 mt-1.5">
|
|
||||||
<ConnectionStatus />
|
|
||||||
</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-foreground bg-gradient-to-r from-muted/80 to-muted/50 backdrop-blur-sm px-4 py-2 rounded-full whitespace-nowrap shadow-lg border border-border/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-card to-background overflow-hidden">
|
|
||||||
<ScrollArea class="h-full" type="hover">
|
|
||||||
<div class="flex flex-col gap-4 px-6 py-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-8">
|
|
||||||
<div
|
|
||||||
class="px-4 py-1.5 rounded-full bg-gradient-to-r from-muted/30 to-muted/30 text-xs font-medium text-muted-foreground shadow-lg backdrop-blur-sm border border-border/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 }">
|
|
||||||
<MessageBubble v-for="(message, messageIndex) in group.messages" :key="message.id" :sent="group.sent"
|
|
||||||
:is-first="messageIndex === 0" :is-last="messageIndex === group.messages.length - 1"
|
|
||||||
:content="message.content" :timestamp="message.created_at"
|
|
||||||
:show-timestamp="messageIndex === group.messages.length - 1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div ref="messagesEndRef" class="h-px" />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter class="flex-shrink-0 border-t border-border/50 bg-background/95 backdrop-blur-md p-4 shadow-xl">
|
|
||||||
<form @submit="sendMessage" class="flex w-full items-center gap-4">
|
|
||||||
<Input id="message" v-model="input" placeholder="Type your message..."
|
|
||||||
class="flex-1 bg-card/90 border-border text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-300 shadow-lg hover:border-border/60 rounded-xl h-11"
|
|
||||||
autocomplete="off" />
|
|
||||||
<Button type="submit" size="icon" :disabled="inputLength === 0 || isSending"
|
|
||||||
class="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground 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">
|
|
||||||
<Send v-if="!isSending" class="h-4 w-4" />
|
|
||||||
<div v-else
|
|
||||||
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-r-transparent" />
|
|
||||||
<span class="sr-only">{{ isSending ? 'Sending...' : 'Send' }}</span>
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardFooter>
|
|
||||||
|
|
||||||
<!-- Add loading indicator if needed -->
|
|
||||||
<div v-if="isSubscribing" class="absolute top-4 right-4 flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<div class="h-2 w-2 rounded-full bg-primary animate-pulse"></div>
|
|
||||||
Connecting...
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.animate-in {
|
|
||||||
animation: animate-in 0.3s cubic-bezier(0.21, 1.02, 0.73, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes animate-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(4px) scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.break-words {
|
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
hyphens: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-none {
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-text {
|
|
||||||
user-select: text;
|
|
||||||
-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% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove any scroll-behavior from parent elements */
|
|
||||||
.scrollarea-viewport {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure proper containment */
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
contain: content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { type DirectoryItem } from '@/types/directory'
|
|
||||||
import {
|
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
ExternalLink,
|
|
||||||
Zap,
|
|
||||||
MessageCircle,
|
|
||||||
Facebook,
|
|
||||||
Instagram,
|
|
||||||
Twitter,
|
|
||||||
Youtube,
|
|
||||||
UtensilsCrossed,
|
|
||||||
Bed,
|
|
||||||
ShoppingBag,
|
|
||||||
Car,
|
|
||||||
Ship,
|
|
||||||
HelpCircle,
|
|
||||||
Sparkles,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
item: DirectoryItem
|
|
||||||
}>()
|
|
||||||
|
|
||||||
|
|
||||||
const categoryIcons = {
|
|
||||||
restaurant: UtensilsCrossed,
|
|
||||||
lodging: Bed,
|
|
||||||
goods: ShoppingBag,
|
|
||||||
services: Sparkles,
|
|
||||||
taxi: Car,
|
|
||||||
tuktuk: TukTuk,
|
|
||||||
lancha: Ship,
|
|
||||||
other: HelpCircle,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
restaurant: 'text-orange-500',
|
|
||||||
lodging: 'text-purple-500',
|
|
||||||
goods: 'text-green-500',
|
|
||||||
services: 'text-pink-500',
|
|
||||||
taxi: 'text-yellow-500',
|
|
||||||
tuktuk: 'text-amber-500',
|
|
||||||
lancha: 'text-blue-500',
|
|
||||||
other: 'text-gray-500',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const socials = {
|
|
||||||
facebook: Facebook,
|
|
||||||
instagram: Instagram,
|
|
||||||
twitter: Twitter,
|
|
||||||
youtube: Youtube,
|
|
||||||
}
|
|
||||||
|
|
||||||
const socialColors = {
|
|
||||||
facebook: 'text-blue-600 hover:text-blue-700',
|
|
||||||
instagram: 'text-pink-600 hover:text-pink-700',
|
|
||||||
twitter: 'text-blue-400 hover:text-blue-500',
|
|
||||||
youtube: 'text-red-600 hover:text-red-700',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Card class="hover:shadow-md transition-shadow relative overflow-hidden group cursor-pointer"
|
|
||||||
@click="$router.push(`/directory/${item.id}`)">
|
|
||||||
<!-- Local watermark -->
|
|
||||||
<!-- <div v-if="item.local" class="absolute right-3 bottom-2 text-2xl tracking-widest font-bold text-primary opacity-30"> -->
|
|
||||||
<!-- LOCAL -->
|
|
||||||
<!-- </div> -->
|
|
||||||
|
|
||||||
<CardContent class="p-6 space-y-4 relative z-20">
|
|
||||||
<!-- Image -->
|
|
||||||
<div v-if="item.imageUrl" class="aspect-video w-full overflow-hidden rounded-md">
|
|
||||||
<img :src="item.imageUrl" :alt="item.name" class="h-full w-full object-cover" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<component :is="categoryIcons[item.category]" class="h-6 w-6" :class="categoryColors[item.category]"
|
|
||||||
:title="item.category" />
|
|
||||||
<CardTitle>{{ item.name }}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardDescription>
|
|
||||||
{{ item.description }}
|
|
||||||
</CardDescription>
|
|
||||||
|
|
||||||
<!-- Contact Info -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div v-if="item.town || item.address" class="flex items-center text-sm group">
|
|
||||||
<MapPin class="mr-2 h-4 w-4 text-red-400" />
|
|
||||||
<a v-if="item.mapsUrl" :href="item.mapsUrl" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors relative"
|
|
||||||
@click.stop>
|
|
||||||
<span>{{ [item.address, item.town].filter(Boolean).join(', ') }}</span>
|
|
||||||
<ExternalLink class="h-3 w-3 ml-1" />
|
|
||||||
</a>
|
|
||||||
<span v-else>{{ [item.address, item.town].filter(Boolean).join(', ') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="item.contact" class="flex items-center text-sm gap-2">
|
|
||||||
<Phone class="h-4 w-4" />
|
|
||||||
<span>{{ item.contact }}</span>
|
|
||||||
<div v-if="item.contactType" class="flex gap-1">
|
|
||||||
<a v-if="item.contactType.includes('whatsapp')" :href="`https://wa.me/${item.contact.replace(/\D/g, '')}`"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="bg-green-100 text-green-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 hover:bg-green-200 transition-colors relative"
|
|
||||||
@click.stop>
|
|
||||||
<MessageCircle class="h-3 w-3" />
|
|
||||||
WhatsApp
|
|
||||||
</a>
|
|
||||||
<a v-if="item.contactType.includes('telegram')" :href="`https://t.me/${item.contact.replace(/\D/g, '')}`"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 hover:bg-blue-200 transition-colors relative"
|
|
||||||
@click.stop>
|
|
||||||
<MessageCircle class="h-3 w-3" />
|
|
||||||
Telegram
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="item.lightning" class="flex items-center text-sm">
|
|
||||||
<Zap class="mr-2 h-4 w-4 text-amber-500" />
|
|
||||||
<a :href="`lightning:${item.lightning}`"
|
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors" @click.stop>
|
|
||||||
{{ item.lightning }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Website -->
|
|
||||||
<div v-if="item.url" class="flex items-center text-sm">
|
|
||||||
<ExternalLink class="mr-2 h-4 w-4" />
|
|
||||||
<a :href="item.url" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors" @click.stop>
|
|
||||||
{{ item.url.replace(/^https?:\/\//, '').split('/')[0] }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Social Media Links -->
|
|
||||||
<div v-if="item.social" class="flex items-center gap-2 text-sm">
|
|
||||||
<template v-for="(url, platform) in item.social" :key="platform">
|
|
||||||
<a v-if="url" :href="url" target="_blank" rel="noopener noreferrer" :class="[
|
|
||||||
'transition-colors relative',
|
|
||||||
socialColors[platform as keyof typeof socialColors]
|
|
||||||
]" :title="platform.charAt(0).toUpperCase() + platform.slice(1)" @click.stop>
|
|
||||||
<component :is="socials[platform as keyof typeof socials]" class="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Search } from 'lucide-vue-next'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
UtensilsCrossed,
|
|
||||||
Bed,
|
|
||||||
ShoppingBag,
|
|
||||||
Sparkles,
|
|
||||||
Car,
|
|
||||||
Ship,
|
|
||||||
HelpCircle,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
category: string
|
|
||||||
search: string
|
|
||||||
town: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:category': [value: string]
|
|
||||||
'update:search': [value: string]
|
|
||||||
'update:town': [value: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const searchValue = ref('')
|
|
||||||
|
|
||||||
// Watch for changes in the search value
|
|
||||||
watch(searchValue, (newValue) => {
|
|
||||||
emit('update:search', newValue)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Watch for prop changes to update local value
|
|
||||||
watch(() => props.search, (newValue) => {
|
|
||||||
searchValue.value = newValue
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
const categoryIcons = {
|
|
||||||
tuktuk: TukTuk,
|
|
||||||
restaurant: UtensilsCrossed,
|
|
||||||
lodging: Bed,
|
|
||||||
goods: ShoppingBag,
|
|
||||||
services: Sparkles,
|
|
||||||
taxi: Car,
|
|
||||||
lancha: Ship,
|
|
||||||
other: HelpCircle,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
tuktuk: 'text-amber-500',
|
|
||||||
restaurant: 'text-orange-500',
|
|
||||||
lodging: 'text-purple-500',
|
|
||||||
goods: 'text-green-500',
|
|
||||||
services: 'text-pink-500',
|
|
||||||
lancha: 'text-blue-500',
|
|
||||||
taxi: 'text-yellow-500',
|
|
||||||
other: 'text-gray-500',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const categories = computed(() => [
|
|
||||||
{ id: 'all', label: t('directory.categories.all') },
|
|
||||||
{ id: 'restaurant', label: t('directory.categories.restaurant'), icon: categoryIcons.restaurant },
|
|
||||||
{ id: 'tuktuk', label: t('directory.categories.tuktuk'), icon: categoryIcons.tuktuk },
|
|
||||||
{ id: 'lodging', label: t('directory.categories.lodging'), icon: categoryIcons.lodging },
|
|
||||||
{ id: 'goods', label: t('directory.categories.goods'), icon: categoryIcons.goods },
|
|
||||||
{ id: 'services', label: t('directory.categories.services'), icon: categoryIcons.services },
|
|
||||||
{ id: 'lancha', label: t('directory.categories.lancha'), icon: categoryIcons.lancha },
|
|
||||||
{ id: 'taxi', label: t('directory.categories.taxi'), icon: categoryIcons.taxi },
|
|
||||||
{ id: 'other', label: t('directory.categories.other'), icon: categoryIcons.other },
|
|
||||||
])
|
|
||||||
|
|
||||||
const towns = computed(() => [
|
|
||||||
{ id: 'all', label: t('directory.towns.all') },
|
|
||||||
{ id: 'San Marcos', label: 'San Marcos' },
|
|
||||||
{ id: 'San Pedro', label: 'San Pedro' },
|
|
||||||
{ id: 'Tzununa', label: 'Tzununa' },
|
|
||||||
{ id: 'Jaibalito', label: 'Jaibalito' },
|
|
||||||
{ id: 'San Pablo', label: 'San Pablo' },
|
|
||||||
{ id: 'Panajachel', label: 'Panajachel' },
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="w-full space-y-3">
|
|
||||||
<!-- Search Input -->
|
|
||||||
<div class="relative w-full max-w-xl mx-auto">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Search class="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<Input v-model="searchValue" type="text" class="pl-10 w-full" :placeholder="t('directory.search')"
|
|
||||||
inputmode="text" enterkeyhint="search" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<!-- Town Filter -->
|
|
||||||
<div class="flex justify-start md:justify-center gap-1 overflow-x-auto pb-2 px-2 md:px-0
|
|
||||||
[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
|
||||||
<Button v-for="to in towns" :key="to.id" @click="emit('update:town', to.id)"
|
|
||||||
:variant="props.town === to.id ? 'default' : 'secondary'" size="sm"
|
|
||||||
class="rounded-full whitespace-nowrap">
|
|
||||||
{{ to.label }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category Filter -->
|
|
||||||
<div class="flex justify-start md:justify-center gap-1 overflow-x-auto pb-2 px-2 md:px-0
|
|
||||||
[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
|
||||||
<Button v-for="cat in categories" :key="cat.id" @click="emit('update:category', cat.id)"
|
|
||||||
:variant="props.category === cat.id ? 'default' : 'secondary'" size="sm"
|
|
||||||
class="rounded-full whitespace-nowrap">
|
|
||||||
<!-- Show only icon on mobile for non-'all' categories -->
|
|
||||||
<template v-if="cat.id !== 'all'">
|
|
||||||
<component :is="cat.icon" class="h-4 w-4 md:mr-2" :class="[
|
|
||||||
props.category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors],
|
|
||||||
'md:hidden'
|
|
||||||
]" />
|
|
||||||
<component :is="cat.icon" class="h-4 w-4 mr-2 hidden md:inline-block"
|
|
||||||
:class="props.category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors]" />
|
|
||||||
<span class="hidden md:inline">{{ cat.label }}</span>
|
|
||||||
</template>
|
|
||||||
<!-- Always show text for 'all' category -->
|
|
||||||
<template v-else>
|
|
||||||
{{ cat.label }}
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import Fuse from 'fuse.js'
|
|
||||||
import DirectoryCard from './DirectoryCard.vue'
|
|
||||||
import { type DirectoryItem } from '@/types/directory'
|
|
||||||
|
|
||||||
const selectedCategory = ref<string>('all')
|
|
||||||
const selectedTown = ref<string>('all')
|
|
||||||
const searchQuery = ref('')
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
items: DirectoryItem[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Configure Fuse.js options
|
|
||||||
const fuseOptions = {
|
|
||||||
keys: [
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'town',
|
|
||||||
'address',
|
|
||||||
'contact',
|
|
||||||
'lightning'
|
|
||||||
],
|
|
||||||
threshold: 0.3, // Lower threshold means more strict matching
|
|
||||||
ignoreLocation: true,
|
|
||||||
shouldSort: true
|
|
||||||
}
|
|
||||||
|
|
||||||
const fuse = new Fuse(props.items, fuseOptions)
|
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
|
||||||
let results = props.items
|
|
||||||
|
|
||||||
// Apply search if query exists
|
|
||||||
if (searchQuery.value) {
|
|
||||||
results = fuse.search(searchQuery.value).map((result: { item: any }) => result.item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply category filter
|
|
||||||
if (selectedCategory.value !== 'all') {
|
|
||||||
results = results.filter(item => item.category === selectedCategory.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply town filter
|
|
||||||
if (selectedTown.value !== 'all') {
|
|
||||||
results = results.filter(item => item.town === selectedTown.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- Grid -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
|
|
||||||
<DirectoryCard v-for="item in filteredItems" :key="item.id" :item="item" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="filteredItems.length === 0" class="text-center py-12">
|
|
||||||
<p class="text-lg text-muted-foreground">
|
|
||||||
No results found. Try adjusting your filters.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { type DirectoryItem } from '@/types/directory'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import {
|
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
ExternalLink,
|
|
||||||
Zap,
|
|
||||||
MessageCircle,
|
|
||||||
Facebook,
|
|
||||||
Instagram,
|
|
||||||
Twitter,
|
|
||||||
Youtube,
|
|
||||||
UtensilsCrossed,
|
|
||||||
Bed,
|
|
||||||
ShoppingBag,
|
|
||||||
Car,
|
|
||||||
Ship,
|
|
||||||
HelpCircle,
|
|
||||||
Sparkles,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
item: DirectoryItem
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const socials = {
|
|
||||||
facebook: Facebook,
|
|
||||||
instagram: Instagram,
|
|
||||||
twitter: Twitter,
|
|
||||||
youtube: Youtube,
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryIcons = {
|
|
||||||
restaurant: UtensilsCrossed,
|
|
||||||
lodging: Bed,
|
|
||||||
goods: ShoppingBag,
|
|
||||||
services: Sparkles,
|
|
||||||
taxi: Car,
|
|
||||||
tuktuk: TukTuk,
|
|
||||||
lancha: Ship,
|
|
||||||
other: HelpCircle,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
restaurant: 'text-orange-500',
|
|
||||||
lodging: 'text-purple-500',
|
|
||||||
goods: 'text-green-500',
|
|
||||||
services: 'text-pink-500',
|
|
||||||
taxi: 'text-yellow-500',
|
|
||||||
tuktuk: 'text-amber-500',
|
|
||||||
lancha: 'text-blue-500',
|
|
||||||
other: 'text-gray-500',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const socialColors = {
|
|
||||||
facebook: 'text-blue-600 hover:text-blue-700',
|
|
||||||
instagram: 'text-pink-600 hover:text-pink-700',
|
|
||||||
twitter: 'text-blue-400 hover:text-blue-500',
|
|
||||||
youtube: 'text-red-600 hover:text-red-700',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Card class="relative overflow-hidden">
|
|
||||||
<!-- Local badge -->
|
|
||||||
<!-- <div v-if="item.local" -->
|
|
||||||
<!-- class="absolute right-6 top-6 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"> -->
|
|
||||||
<!-- LOCAL -->
|
|
||||||
<!-- </div> -->
|
|
||||||
|
|
||||||
<!-- Image Banner -->
|
|
||||||
<div v-if="item.imageUrl" class="w-full h-64 overflow-hidden">
|
|
||||||
<img :src="item.imageUrl" :alt="item.name" class="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardHeader>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<component :is="categoryIcons[item.category]" class="h-8 w-8" :class="categoryColors[item.category]"
|
|
||||||
:title="t(`directory.categories.${item.category}`)" />
|
|
||||||
<CardTitle class="text-3xl">{{ item.name }}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CardDescription v-if="item.description" class="text-base mt-4">
|
|
||||||
{{ item.description }}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
|
||||||
<!-- Left Column -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Location -->
|
|
||||||
<div v-if="item.town || item.address" class="flex items-start gap-3 text-base">
|
|
||||||
<MapPin class="h-5 w-5 text-red-500 mt-0.5" />
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div>{{ [item.address, item.town].filter(Boolean).join(', ') }}</div>
|
|
||||||
<a v-if="item.mapsUrl" :href="item.mapsUrl" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
{{ t('directory.viewMap') }}
|
|
||||||
<ExternalLink class="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phone -->
|
|
||||||
<div v-if="item.contact" class="flex items-start gap-3 text-base">
|
|
||||||
<Phone class="h-5 w-5 mt-0.5" />
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div>{{ item.contact }}</div>
|
|
||||||
<div v-if="item.contactType" class="flex gap-2">
|
|
||||||
<a v-if="item.contactType.includes('whatsapp')"
|
|
||||||
:href="`https://wa.me/${item.contact.replace(/\D/g, '')}`" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-800 hover:bg-green-200 transition-colors">
|
|
||||||
<MessageCircle class="h-4 w-4" />
|
|
||||||
WhatsApp
|
|
||||||
</a>
|
|
||||||
<a v-if="item.contactType.includes('telegram')"
|
|
||||||
:href="`https://t.me/${item.contact.replace(/\D/g, '')}`" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800 hover:bg-blue-200 transition-colors">
|
|
||||||
<MessageCircle class="h-4 w-4" />
|
|
||||||
Telegram
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lightning Address -->
|
|
||||||
<div v-if="item.lightning" class="flex items-center gap-3 text-base">
|
|
||||||
<Zap class="h-5 w-5 text-amber-500" />
|
|
||||||
<a :href="`lightning:${item.lightning}`"
|
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
{{ item.lightning }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Website -->
|
|
||||||
<div v-if="item.url" class="flex items-center gap-3 text-base">
|
|
||||||
<ExternalLink class="h-5 w-5" />
|
|
||||||
<a :href="item.url" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
{{ item.url.replace(/^https?:\/\//, '') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Social Media -->
|
|
||||||
<div v-if="item.social" class="space-y-2">
|
|
||||||
<div v-for="(url, platform) in item.social" :key="platform" class="flex items-center gap-3">
|
|
||||||
<component :is="socials[platform as keyof typeof socials]" class="h-5 w-5"
|
|
||||||
:class="socialColors[platform as keyof typeof socialColors]" />
|
|
||||||
<a :href="url" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
{{ platform.charAt(0).toUpperCase() + platform.slice(1) }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g>
|
|
||||||
<path fill="currentColor" d="M55.467,315.733C24.892,315.733,0,340.617,0,371.2c0,30.583,24.892,55.467,55.467,55.467s55.467-24.883,55.467-55.467
|
|
||||||
C110.933,340.617,86.05,315.733,55.467,315.733z M55.467,409.6c-21.171,0-38.4-17.229-38.4-38.4s17.229-38.4,38.4-38.4
|
|
||||||
s38.4,17.229,38.4,38.4S76.646,409.6,55.467,409.6z"/>
|
|
||||||
<path fill="currentColor" d="M503.467,256h-8.533v-93.867c0-38.05-27.819-69.734-64.188-75.768c-1.212-0.657-2.603-1.033-4.079-1.033H153.6
|
|
||||||
c-0.29,0-0.572,0.009-0.862,0.043c-54.443,5.513-88.055,98.987-99.388,136.491H42.667c-4.71,0-8.533,3.823-8.533,8.533v34.133
|
|
||||||
c0,4.71,3.823,8.533,8.533,8.533h25.6v8.96c-2.807-0.282-5.649-0.427-8.533-0.427c-21.035,0-41.242,7.714-56.892,21.734
|
|
||||||
c-3.516,3.14-3.806,8.533-0.666,12.049c3.149,3.516,8.533,3.814,12.049,0.666c12.518-11.204,28.689-17.382,45.508-17.382
|
|
||||||
c37.641,0,68.267,30.626,68.267,68.267c0,4.71,3.823,8.533,8.533,8.533h222.549c4.164,28.902,29.022,51.2,59.051,51.2
|
|
||||||
c30.029,0,54.886-22.298,59.051-51.2h9.216c4.71,0,8.533-3.823,8.533-8.533V307.2h8.533c4.71,0,8.533-3.823,8.533-8.533v-34.133
|
|
||||||
C512,259.823,508.186,256,503.467,256z M154.061,102.4h264.073c32.939,0,59.733,26.795,59.733,59.733v85.333H358.4V128
|
|
||||||
c0-4.71-3.823-8.533-8.533-8.533H147.652l-13.406-8.943C140.629,106.044,147.243,103.168,154.061,102.4z M341.333,136.533v115.934
|
|
||||||
L309.7,284.1c-1.596,1.596-2.5,3.772-2.5,6.033v34.133h-59.733V136.533H341.333z M230.4,136.533v85.333H128.316l23.27-85.333
|
|
||||||
H230.4z M121.122,122.291l14.097,9.404l-23.287,85.393l-33.434-16.717C89.762,169.694,104.346,140.706,121.122,122.291z
|
|
||||||
M418.133,409.6c-20.599,0-37.837-14.686-41.805-34.133h83.61C455.979,394.914,438.741,409.6,418.133,409.6z M494.933,290.133
|
|
||||||
H486.4c-4.71,0-8.533,3.823-8.533,8.533V358.4H144.64c-3.43-34.372-27.332-62.805-59.307-72.883v-20.983
|
|
||||||
c0-4.71-3.823-8.533-8.533-8.533H51.2v-17.067h8.533c3.806,0,7.159-2.526,8.201-6.187c1.527-5.342,3.2-10.718,4.949-16.094
|
|
||||||
l42.769,21.385c1.186,0.589,2.492,0.896,3.814,0.896H230.4V332.8c0,4.71,3.823,8.533,8.533,8.533h76.8
|
|
||||||
c4.71,0,8.533-3.823,8.533-8.533v-39.134l29.141-29.133h124.459c0,4.71,3.823,8.533,8.533,8.533h8.533V290.133z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { Menu, X, Sun, Moon, Zap, MessageSquareText, LogIn } from 'lucide-vue-next'
|
import { Menu, X, Sun, Moon, Zap, MessageSquareText, LogIn } 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'
|
import { useRouter } from 'vue-router'
|
||||||
import LogoutDialog from '@/components/ui/logout-dialog/LogoutDialog.vue'
|
import LogoutDialog from '@/components/ui/logout-dialog/LogoutDialog.vue'
|
||||||
import Login from '@/components/Login.vue'
|
import Login from '@/components/Login.vue'
|
||||||
|
|
@ -13,15 +12,13 @@ import ConnectionStatus from '@/components/ConnectionStatus.vue'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const nostrStore = useNostrStore()
|
// const nostrStore = useNostrStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
const showLoginDialog = ref(false)
|
const showLoginDialog = ref(false)
|
||||||
|
|
||||||
const navigation = computed(() => [
|
const navigation = computed(() => [
|
||||||
{ name: t('nav.home'), href: '/' },
|
{ name: t('nav.home'), href: '/' },
|
||||||
{ name: t('nav.directory'), href: '/directory' },
|
|
||||||
{ name: t('nav.faq'), href: '/faq' },
|
|
||||||
{ name: t('nav.support'), href: '/support', icon: MessageSquareText },
|
{ name: t('nav.support'), href: '/support', icon: MessageSquareText },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -42,7 +39,7 @@ const toggleLocale = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await nostrStore.logout()
|
// await nostrStore.logout()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,9 +85,11 @@ const openLogin = () => {
|
||||||
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ConnectionStatus v-if="nostrStore.isLoggedIn" />
|
<!-- <ConnectionStatus v-if="nostrStore.isLoggedIn" /> -->
|
||||||
|
<ConnectionStatus v-if="true" />
|
||||||
|
|
||||||
<template v-if="nostrStore.isLoggedIn">
|
<!-- <template v-if="nostrStore.isLoggedIn"> -->
|
||||||
|
<template v-if="true">
|
||||||
<LogoutDialog :onLogout="handleLogout" />
|
<LogoutDialog :onLogout="handleLogout" />
|
||||||
</template>
|
</template>
|
||||||
<Button v-else variant="ghost" size="icon" @click="openLogin"
|
<Button v-else variant="ghost" size="icon" @click="openLogin"
|
||||||
|
|
@ -118,8 +117,7 @@ const openLogin = () => {
|
||||||
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
||||||
<Moon v-else class="h-5 w-5" />
|
<Moon v-else class="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost"
|
<Button variant="ghost" class="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
@click="toggleLocale">
|
@click="toggleLocale">
|
||||||
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ type Theme = 'dark' | 'light' | 'system'
|
||||||
const useTheme = () => {
|
const useTheme = () => {
|
||||||
const theme = ref<Theme>('dark')
|
const theme = ref<Theme>('dark')
|
||||||
const systemTheme = ref<'dark' | 'light'>('light')
|
const systemTheme = ref<'dark' | 'light'>('light')
|
||||||
const currentTown = ref(localStorage.getItem('current-town') || 'all')
|
|
||||||
|
|
||||||
const updateSystemTheme = () => {
|
const updateSystemTheme = () => {
|
||||||
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
|
@ -43,18 +42,11 @@ const useTheme = () => {
|
||||||
localStorage.setItem('ui-theme', newTheme)
|
localStorage.setItem('ui-theme', newTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setCurrentTown = (town: string) => {
|
|
||||||
currentTown.value = town
|
|
||||||
localStorage.setItem('current-town', town)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
systemTheme,
|
systemTheme,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
currentTown,
|
|
||||||
setCurrentTown
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useMessageStore } from '@/stores/messages'
|
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
|
||||||
|
|
||||||
export function useChat(pubkey: string) {
|
|
||||||
const messageStore = useMessageStore()
|
|
||||||
const nostrStore = useNostrStore()
|
|
||||||
|
|
||||||
const messages = computed(() =>
|
|
||||||
messageStore.messages.get(pubkey) || []
|
|
||||||
)
|
|
||||||
|
|
||||||
const sendMessage = async (content: string) => {
|
|
||||||
if (!content.trim()) return
|
|
||||||
await nostrStore.sendMessage(pubkey, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadHistory = async () => {
|
|
||||||
await nostrStore.subscribeToMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
sendMessage,
|
|
||||||
loadHistory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
import { type DirectoryItem } from '@/types/directory'
|
|
||||||
|
|
||||||
export const mockDirectoryItems: DirectoryItem[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Ixchel Cafe & Bakery',
|
|
||||||
category: 'restaurant',
|
|
||||||
description: 'A cozy cafe serving great coffee and accepting Bitcoin Lightning payments.',
|
|
||||||
url: 'https://ixchel.atitlan.io',
|
|
||||||
town: 'San Marcos',
|
|
||||||
mapsUrl: 'https://maps.app.goo.gl/sbjmvqP8U4SB4FS29',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/emporium.atitlan'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Axel',
|
|
||||||
category: 'tuktuk',
|
|
||||||
town: 'San Marcos',
|
|
||||||
contact: '+502 3846 1220',
|
|
||||||
contactType: ['whatsapp', 'telegram'],
|
|
||||||
lightning: 'axel@atitlan.io'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'Atitlan Muay Thai',
|
|
||||||
category: 'services',
|
|
||||||
town: 'San Pedro',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/muaythaiatitlan'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'Zoe Nails & Spa',
|
|
||||||
category: 'services',
|
|
||||||
town: 'Panajachel',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/zoenailsyspapanajachel'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
name: 'Full Print',
|
|
||||||
category: 'services',
|
|
||||||
town: 'San Pedro',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/profile.php?id=100057645572968'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
name: 'Multiservicios Yaxon',
|
|
||||||
category: 'services',
|
|
||||||
town: 'Tzununa',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Multiservicios-Yaxón-299907600397260'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '7',
|
|
||||||
name: 'Utz Kab',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Pablo',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/UTZ-KAB-534232490075686'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '8',
|
|
||||||
name: 'Utz Color Fashion',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Pedro',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/UtzColorFashion'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '9',
|
|
||||||
name: 'Do Bau',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Pedro',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Adrianamatrioshka'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '10',
|
|
||||||
name: 'Caffé Kitsch',
|
|
||||||
category: 'restaurant',
|
|
||||||
town: 'Panajachel',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Kitschers'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '11',
|
|
||||||
name: 'Hotel Corazon del Mundo Fresh',
|
|
||||||
category: 'lodging',
|
|
||||||
town: 'Jaibalito',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/corazondelmundofresh'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '12',
|
|
||||||
name: 'Hostel Fe San Marcos',
|
|
||||||
category: 'lodging',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/HostelFeSanMarcos'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '13',
|
|
||||||
name: 'Hotel Casa Maya',
|
|
||||||
category: 'lodging',
|
|
||||||
town: 'San Marcos',
|
|
||||||
mapsUrl: 'https://www.google.com/maps/place/Hotel+Casa+Maya'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '15',
|
|
||||||
name: 'Artesanias San Marcos La Laguna',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Artesanias-San-Marcos-La-Laguna-102826589071628/'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '16',
|
|
||||||
name: 'Textiles Felix San Marcos La Laguna',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Textiles-Felix-San-Marcos-La-Laguna-102732085750559'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '17',
|
|
||||||
name: 'Health Food Store San Jose',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Health-Food-Store-San-Jose-719299235179691'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '18',
|
|
||||||
name: 'Tienda San Jose 2',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Marcos',
|
|
||||||
mapsUrl: 'https://www.google.com/maps/place/Tienda+San+José+%232',
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '19',
|
|
||||||
name: 'Sound Temple San Marcos',
|
|
||||||
category: 'services',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/SoundTempleSanMarcos'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '21',
|
|
||||||
name: 'Veda',
|
|
||||||
category: 'restaurant',
|
|
||||||
town: 'Tzununa',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/vedafoodismedicine'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '22',
|
|
||||||
name: 'Bambu Guest House',
|
|
||||||
category: 'lodging',
|
|
||||||
town: 'Tzununa',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/bambuguesthousegt'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '23',
|
|
||||||
name: 'Atitlan Organics',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'Tzununa',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/atitlanorganics'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '24',
|
|
||||||
name: 'Holy Wow Cacao',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'Tzununa',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/HolyWowCacao'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '25',
|
|
||||||
name: 'Bitcoin Lake Lancha (fake)',
|
|
||||||
category: 'lancha',
|
|
||||||
address: 'Pier 21, Harbor Front',
|
|
||||||
contact: '+1 234-567-8902'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '26',
|
|
||||||
name: 'Tor\'s Drums',
|
|
||||||
category: 'goods',
|
|
||||||
town: 'San Marcos',
|
|
||||||
contact: '+502 4900 1279',
|
|
||||||
contactType: ['whatsapp'],
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/share/1DcBdJhuFH/'
|
|
||||||
},
|
|
||||||
lightning: 'tor@atitlan.io'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '27',
|
|
||||||
name: 'Jade Maya',
|
|
||||||
category: 'goods',
|
|
||||||
local: true,
|
|
||||||
town: 'San Marcos',
|
|
||||||
mapsUrl: 'https://maps.app.goo.gl/kZiKdM2FFAw1TQMN8',
|
|
||||||
lightning: 'osman@atitlan.io',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '28',
|
|
||||||
name: 'La Sala del Lago',
|
|
||||||
category: 'restaurant',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/La-Sala-Del-Lago-100220539146301'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '29',
|
|
||||||
name: 'Nectar',
|
|
||||||
category: 'restaurant',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/lovenectar'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '30',
|
|
||||||
name: 'Arati Cafe',
|
|
||||||
category: 'restaurant',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Arati-Cafe-105767784719695'
|
|
||||||
},
|
|
||||||
local: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '31',
|
|
||||||
name: 'Fe Restaurant',
|
|
||||||
category: 'restaurant',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/fesanmarcos'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '32',
|
|
||||||
name: 'Hostal del Lago',
|
|
||||||
category: 'lodging',
|
|
||||||
town: 'San Marcos',
|
|
||||||
social: {
|
|
||||||
facebook: 'https://www.facebook.com/Hostel-Del-Lago-605530306467708'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export class NostrEncryption {
|
|
||||||
static async encrypt(privkey: string, pubkey: string, content: string) {
|
|
||||||
return await window.NostrTools.nip04.encrypt(privkey, pubkey, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async decrypt(privkey: string, pubkey: string, content: string) {
|
|
||||||
return await window.NostrTools.nip04.decrypt(privkey, pubkey, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
export class NostrError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public code: string,
|
|
||||||
public context?: any
|
|
||||||
) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'NostrError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleNostrError(error: unknown) {
|
|
||||||
if (error instanceof NostrError) {
|
|
||||||
switch (error.code) {
|
|
||||||
case 'CONNECTION_FAILED':
|
|
||||||
return 'Failed to connect to relay. Please check your connection.'
|
|
||||||
case 'DECRYPT_FAILED':
|
|
||||||
return 'Failed to decrypt message.'
|
|
||||||
default:
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'An unexpected error occurred'
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import type { DirectMessage } from '@/types/nostr'
|
|
||||||
|
|
||||||
export class MessageManager {
|
|
||||||
private messages = new Map<string, DirectMessage[]>()
|
|
||||||
private processedIds = new Set<string>()
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.loadFromStorage()
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(pubkey: string, message: DirectMessage) {
|
|
||||||
if (this.processedIds.has(message.id)) return false
|
|
||||||
if (this.isDuplicate(pubkey, message)) return false
|
|
||||||
|
|
||||||
this.processedIds.add(message.id)
|
|
||||||
const messages = [...(this.messages.get(pubkey) || []), message]
|
|
||||||
messages.sort((a, b) => a.created_at - b.created_at)
|
|
||||||
this.messages.set(pubkey, messages)
|
|
||||||
this.saveToStorage()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private isDuplicate(pubkey: string, message: DirectMessage) {
|
|
||||||
const existing = this.messages.get(pubkey) || []
|
|
||||||
return existing.some(msg =>
|
|
||||||
msg.content === message.content &&
|
|
||||||
Math.abs(msg.created_at - message.created_at) < 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadFromStorage() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('nostr_messages')
|
|
||||||
if (stored) {
|
|
||||||
this.messages = new Map(JSON.parse(stored))
|
|
||||||
this.messages.forEach(msgs =>
|
|
||||||
msgs.forEach(msg => this.processedIds.add(msg.id))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load messages:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveToStorage() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
'nostr_messages',
|
|
||||||
JSON.stringify(Array.from(this.messages.entries()))
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save messages:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import {
|
|
||||||
getPublicKey,
|
|
||||||
generateSecretKey,
|
|
||||||
nip04,
|
|
||||||
getEventHash,
|
|
||||||
finalizeEvent,
|
|
||||||
validateEvent,
|
|
||||||
nip19,
|
|
||||||
SimplePool,
|
|
||||||
type Event,
|
|
||||||
type Filter
|
|
||||||
} from 'nostr-tools';
|
|
||||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
|
||||||
|
|
||||||
// Expose NostrTools to the window object
|
|
||||||
(window as any).NostrTools = {
|
|
||||||
getPublicKey,
|
|
||||||
generatePrivateKey: () => bytesToHex(generateSecretKey()),
|
|
||||||
nip04,
|
|
||||||
getEventHash,
|
|
||||||
getSignature: (event: Event, privateKey: string) => {
|
|
||||||
const signedEvent = finalizeEvent(event, hexToBytes(privateKey));
|
|
||||||
return signedEvent.sig;
|
|
||||||
},
|
|
||||||
signEvent: (event: Event, privateKey: string) => {
|
|
||||||
const signedEvent = finalizeEvent(event, hexToBytes(privateKey));
|
|
||||||
return signedEvent.sig;
|
|
||||||
},
|
|
||||||
verifySignature: validateEvent,
|
|
||||||
nip19,
|
|
||||||
relayInit: (url: string) => {
|
|
||||||
const pool = new SimplePool();
|
|
||||||
return {
|
|
||||||
connect: async () => {
|
|
||||||
await pool.ensureRelay(url);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
sub: (filters: Filter[]) => {
|
|
||||||
return {
|
|
||||||
on: (type: string, callback: (event: Event) => void) => {
|
|
||||||
if (type === 'event') {
|
|
||||||
void pool.subscribeMany(
|
|
||||||
[url],
|
|
||||||
filters,
|
|
||||||
{ onevent: callback }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
publish: (event: Event) => {
|
|
||||||
return {
|
|
||||||
on: (type: string, cb: (msg?: string) => void) => {
|
|
||||||
if (type === 'ok') {
|
|
||||||
Promise.all(pool.publish([url], event))
|
|
||||||
.then(() => cb())
|
|
||||||
.catch((err: Error) => cb(err.message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
close: () => {
|
|
||||||
pool.close([url]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
123
src/lib/nostr.ts
123
src/lib/nostr.ts
|
|
@ -1,123 +0,0 @@
|
||||||
import type { NostrEvent, NostrRelayConfig } from '../types/nostr'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
NostrTools: {
|
|
||||||
getPublicKey: (privkey: string) => string
|
|
||||||
generatePrivateKey: () => string
|
|
||||||
nip04: {
|
|
||||||
encrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
|
||||||
decrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
|
||||||
}
|
|
||||||
getEventHash: (event: NostrEvent) => string
|
|
||||||
signEvent: (event: NostrEvent, privkey: string) => Promise<string>
|
|
||||||
getSignature: (event: NostrEvent, privkey: string) => string
|
|
||||||
verifySignature: (event: NostrEvent) => boolean
|
|
||||||
nip19: {
|
|
||||||
decode: (str: string) => { type: string; data: string }
|
|
||||||
npubEncode: (hex: string) => string
|
|
||||||
}
|
|
||||||
relayInit: (url: string) => {
|
|
||||||
connect: () => Promise<void>
|
|
||||||
sub: (filters: any[]) => {
|
|
||||||
on: (event: string, callback: (event: NostrEvent) => void) => void
|
|
||||||
}
|
|
||||||
publish: (event: NostrEvent) => {
|
|
||||||
on: (type: 'ok' | 'failed', cb: (msg?: string) => void) => void
|
|
||||||
}
|
|
||||||
close: () => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectToRelay(url: string) {
|
|
||||||
const relay = window.NostrTools.relayInit(url)
|
|
||||||
try {
|
|
||||||
await relay.connect()
|
|
||||||
return relay
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to connect to ${url}:`, err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function publishEvent(event: NostrEvent, relays: NostrRelayConfig[]) {
|
|
||||||
const connectedRelays = await Promise.all(
|
|
||||||
relays.map(relay => connectToRelay(relay.url))
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeRelays = connectedRelays.filter(relay => relay !== null)
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
activeRelays.map(relay =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
const pub = relay.publish(event)
|
|
||||||
pub.on('ok', () => {
|
|
||||||
resolve(true)
|
|
||||||
})
|
|
||||||
pub.on('failed', () => {
|
|
||||||
resolve(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function encryptMessage(privkey: string, pubkey: string, content: string): Promise<string> {
|
|
||||||
return await window.NostrTools.nip04.encrypt(privkey, pubkey, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decryptMessage(privkey: string, pubkey: string, content: string): Promise<string> {
|
|
||||||
return await window.NostrTools.nip04.decrypt(privkey, pubkey, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generatePrivateKey(): string {
|
|
||||||
return window.NostrTools.generatePrivateKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicKey(privateKey: string): string {
|
|
||||||
return window.NostrTools.getPublicKey(privateKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEventHash(event: NostrEvent): string {
|
|
||||||
return window.NostrTools.getEventHash(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signEvent(event: NostrEvent, privateKey: string): Promise<string> {
|
|
||||||
return window.NostrTools.getSignature(event, privateKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifySignature(event: NostrEvent): boolean {
|
|
||||||
return window.NostrTools.verifySignature(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function npubToHex(npub: string): string {
|
|
||||||
try {
|
|
||||||
const { type, data } = window.NostrTools.nip19.decode(npub)
|
|
||||||
if (type !== 'npub') throw new Error('Invalid npub')
|
|
||||||
return data
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to decode npub:', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hexToNpub(hex: string): string {
|
|
||||||
return window.NostrTools.nip19.npubEncode(hex)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidPrivateKey(key: string): boolean {
|
|
||||||
try {
|
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatPrivateKey(key: string): string {
|
|
||||||
return key.trim().toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import type { DirectMessage } from '@/types/nostr'
|
|
||||||
|
|
||||||
export class MessageStorage {
|
|
||||||
static saveMessages(pubkey: string, messages: DirectMessage[]) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
`messages_${pubkey}`,
|
|
||||||
JSON.stringify(messages)
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save messages:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static loadMessages(pubkey: string): DirectMessage[] {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(`messages_${pubkey}`)
|
|
||||||
return stored ? JSON.parse(stored) : []
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load messages:', err)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import type { NostrEvent } from '@/types/nostr'
|
|
||||||
|
|
||||||
export class SubscriptionManager {
|
|
||||||
private currentSubs: any[] = []
|
|
||||||
private isActive = false
|
|
||||||
|
|
||||||
async subscribe(relay: any, filters: any[], handlers: {
|
|
||||||
onEvent: (event: NostrEvent) => void,
|
|
||||||
onEose?: () => void
|
|
||||||
}) {
|
|
||||||
if (this.isActive) return
|
|
||||||
|
|
||||||
this.isActive = true
|
|
||||||
const sub = relay.sub(filters)
|
|
||||||
|
|
||||||
sub.on('event', handlers.onEvent)
|
|
||||||
if (handlers.onEose) {
|
|
||||||
sub.on('eose', handlers.onEose)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSubs.push(sub)
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe() {
|
|
||||||
this.currentSubs.forEach(sub => {
|
|
||||||
try {
|
|
||||||
if (sub?.unsub) sub.unsub()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to unsubscribe:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.currentSubs = []
|
|
||||||
this.isActive = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { withTimeout } from '@/lib/utils'
|
|
||||||
import type { NostrEvent } from '@/types/nostr'
|
|
||||||
|
|
||||||
// Create a new WebSocket manager class
|
|
||||||
export class NostrWebSocketManager {
|
|
||||||
private relayPool: any[] = []
|
|
||||||
private subscriptions = new Map<string, any>()
|
|
||||||
|
|
||||||
async connect(relays: { url: string }[]) {
|
|
||||||
// Close existing connections
|
|
||||||
await this.disconnect()
|
|
||||||
|
|
||||||
// Connect to all relays
|
|
||||||
this.relayPool = (await Promise.all(
|
|
||||||
relays.map(relay => this.connectToRelay(relay.url))
|
|
||||||
)).filter((relay): relay is any => relay !== null)
|
|
||||||
|
|
||||||
return this.relayPool.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private async connectToRelay(url: string) {
|
|
||||||
const relay = window.NostrTools.relayInit(url)
|
|
||||||
try {
|
|
||||||
await withTimeout(relay.connect())
|
|
||||||
return relay
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to connect to ${url}:`, err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async publish(event: NostrEvent, relays: { url: string }[]) {
|
|
||||||
return Promise.all(
|
|
||||||
relays.map(({ url }) => this.publishToRelay(event, url))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.relayPool.forEach(relay => relay.close())
|
|
||||||
this.relayPool = []
|
|
||||||
this.subscriptions.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
get isConnected() {
|
|
||||||
return this.relayPool.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private async publishToRelay(event: NostrEvent, url: string) {
|
|
||||||
const relay = window.NostrTools.relayInit(url)
|
|
||||||
try {
|
|
||||||
await relay.connect()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const pub = relay.publish(event)
|
|
||||||
pub.on('ok', () => resolve(true))
|
|
||||||
pub.on('failed', reject)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to publish to ${url}:`, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ import router from './router'
|
||||||
import { i18n } from './i18n'
|
import { i18n } from './i18n'
|
||||||
import './assets/index.css'
|
import './assets/index.css'
|
||||||
import { registerSW } from 'virtual:pwa-register'
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
import './lib/nostr-bundle'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import DirectoryGrid from '@/components/directory/DirectoryGrid.vue'
|
|
||||||
import DirectoryFilter from '@/components/directory/DirectoryFilter.vue'
|
|
||||||
import { mockDirectoryItems } from '@/data/directory'
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useTheme } from '@/components/theme-provider'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const { currentTown } = useTheme()
|
|
||||||
|
|
||||||
// Use the imported mock data
|
|
||||||
const items = mockDirectoryItems
|
|
||||||
|
|
||||||
const category = ref('all')
|
|
||||||
const search = ref('')
|
|
||||||
const town = ref(currentTown.value)
|
|
||||||
|
|
||||||
// Watch for route changes to update filters
|
|
||||||
watch(() => route.query, (newQuery) => {
|
|
||||||
category.value = newQuery.category?.toString() || 'all'
|
|
||||||
search.value = newQuery.search?.toString() || ''
|
|
||||||
town.value = newQuery.town === undefined ? currentTown.value : (newQuery.town?.toString() || 'all')
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Watch for filter changes and update URL
|
|
||||||
watch([category, search, town], ([newCategory, newSearch, newTown]) => {
|
|
||||||
// Don't update URL if it matches current query params
|
|
||||||
if (
|
|
||||||
newCategory === route.query.category &&
|
|
||||||
newSearch === route.query.search &&
|
|
||||||
newTown === route.query.town
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = {
|
|
||||||
...(newCategory !== 'all' && { category: newCategory }),
|
|
||||||
...(newSearch && { search: newSearch }),
|
|
||||||
...(newTown !== currentTown.value && { town: newTown })
|
|
||||||
}
|
|
||||||
router.replace({ query })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filter items based on category, search, and town
|
|
||||||
const filteredItems = computed(() => {
|
|
||||||
return items.filter(item => {
|
|
||||||
const matchesCategory = category.value === 'all' || item.category === category.value
|
|
||||||
const matchesSearch = !search.value ||
|
|
||||||
item.name.toLowerCase().includes(search.value.toLowerCase()) ||
|
|
||||||
item.description?.toLowerCase().includes(search.value.toLowerCase())
|
|
||||||
const matchesTown = town.value === 'all' || item.town === town.value
|
|
||||||
return matchesCategory && matchesSearch && matchesTown
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="px-0 md:container md:px-4 py-2 md:py-8">
|
|
||||||
<div class="space-y-4 md:space-y-6">
|
|
||||||
<!-- Directory Header -->
|
|
||||||
<div class="text-center space-y-2 mb-2 md:mb-6">
|
|
||||||
<h1 class="md:hidden text-lg font-semibold tracking-tight px-4">
|
|
||||||
{{ t('directory.title') }}
|
|
||||||
</h1>
|
|
||||||
<div class="hidden md:block space-y-2">
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">
|
|
||||||
{{ t('directory.title') }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-sm sm:text-base text-muted-foreground">
|
|
||||||
{{ t('directory.subtitle') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sticky Container for Filter -->
|
|
||||||
<div class="sticky top-14 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b md:border-b-0 w-full shadow-sm transition-all duration-200">
|
|
||||||
<div class="w-full">
|
|
||||||
<DirectoryFilter v-model:category="category" v-model:search="search" v-model:town="town" class="py-3 px-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Directory Grid -->
|
|
||||||
<div class="pt-4 px-4 md:px-0">
|
|
||||||
<DirectoryGrid :items="filteredItems" class="transition-all duration-300" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fade-move,
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-leave-active {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Share2, Copy, Check } from 'lucide-vue-next'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import DirectoryItemDetail from '@/components/directory/DirectoryItemDetail.vue'
|
|
||||||
import { type DirectoryItem } from '@/types/directory'
|
|
||||||
import { mockDirectoryItems } from '@/data/directory'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
id: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const item = ref<DirectoryItem | null>(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref(false)
|
|
||||||
const justCopied = ref(false)
|
|
||||||
|
|
||||||
// Check if Web Share API is available
|
|
||||||
const canShare = computed(() => typeof navigator !== 'undefined' && !!navigator.share)
|
|
||||||
|
|
||||||
// Share functionality
|
|
||||||
const shareItem = async () => {
|
|
||||||
if (!item.value) return
|
|
||||||
|
|
||||||
const shareData = {
|
|
||||||
title: item.value.name,
|
|
||||||
text: item.value.description || `Check out ${item.value.name} on Atitlan Directory`,
|
|
||||||
url: window.location.href
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canShare.value) {
|
|
||||||
try {
|
|
||||||
await navigator.share(shareData)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && err.name !== 'AbortError') {
|
|
||||||
console.error('Error sharing:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to copying the URL
|
|
||||||
await copyToClipboard()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy to clipboard functionality
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(window.location.href)
|
|
||||||
justCopied.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
justCopied.value = false
|
|
||||||
}, 2000)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const found = mockDirectoryItems.find(i => i.id === props.id)
|
|
||||||
if (!found) {
|
|
||||||
error.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
item.value = found
|
|
||||||
} catch (e) {
|
|
||||||
error.value = true
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
|
||||||
<div class="animate-pulse">
|
|
||||||
<div class="h-8 bg-muted rounded w-3/4 mx-auto mb-4"></div>
|
|
||||||
<div class="h-4 bg-muted rounded w-1/2 mx-auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="error" class="text-center py-12">
|
|
||||||
<h2 class="text-xl font-semibold mb-2">{{ t('directory.itemNotFound') }}</h2>
|
|
||||||
<p class="text-muted-foreground mb-4">{{ t('directory.itemNotFoundDesc') }}</p>
|
|
||||||
<router-link to="/directory"
|
|
||||||
class="inline-flex items-center justify-center rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90">
|
|
||||||
{{ t('directory.backToDirectory') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Directory Item -->
|
|
||||||
<template v-else-if="item">
|
|
||||||
<div class="mb-6 flex justify-between items-center">
|
|
||||||
<router-link to="/directory"
|
|
||||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
← {{ t('directory.backToDirectory') }}
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<!-- Share Button -->
|
|
||||||
<Button variant="outline" size="sm" @click="shareItem">
|
|
||||||
<template v-if="canShare">
|
|
||||||
<Share2 class="h-4 w-4 mr-2" />
|
|
||||||
{{ t('directory.share') }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<Copy v-if="!justCopied" class="h-4 w-4 mr-2" />
|
|
||||||
<Check v-else class="h-4 w-4 mr-2" />
|
|
||||||
{{ justCopied ? t('directory.linkCopied') : t('directory.copyLink') }}
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DirectoryItemDetail :item="item" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@/components/ui/accordion'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const faqs = computed(() => [
|
|
||||||
{
|
|
||||||
question: t('faq.items.0.question'),
|
|
||||||
answer: t('faq.items.0.answer')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: t('faq.items.1.question'),
|
|
||||||
answer: t('faq.items.1.answer')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: t('faq.items.2.question'),
|
|
||||||
answer: t('faq.items.2.answer')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: t('faq.items.3.question'),
|
|
||||||
answer: t('faq.items.3.answer')
|
|
||||||
}
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="max-w-3xl mx-auto">
|
|
||||||
<h1 class="text-3xl font-bold text-center mb-8">
|
|
||||||
{{ t('faq.title') }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible>
|
|
||||||
<AccordionItem v-for="(faq, index) in faqs" :key="index" :value="'item-' + index">
|
|
||||||
<AccordionTrigger>{{ faq.question }}</AccordionTrigger>
|
|
||||||
<AccordionContent>{{ faq.answer }}</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,137 +1,10 @@
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useTheme } from '@/components/theme-provider'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import {
|
|
||||||
UtensilsCrossed,
|
|
||||||
Bed,
|
|
||||||
ShoppingBag,
|
|
||||||
Sparkles,
|
|
||||||
Car,
|
|
||||||
Ship,
|
|
||||||
HelpCircle,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
|
||||||
import { type DirectoryItem } from '@/types/directory'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { currentTown, setCurrentTown } = useTheme()
|
|
||||||
|
|
||||||
type CategoryType = DirectoryItem['category']
|
|
||||||
|
|
||||||
const categories: CategoryType[] = ['tuktuk', 'restaurant', 'services', 'goods', 'lodging', 'taxi', 'lancha']
|
|
||||||
|
|
||||||
const categoryIcons = {
|
|
||||||
restaurant: UtensilsCrossed,
|
|
||||||
lodging: Bed,
|
|
||||||
goods: ShoppingBag,
|
|
||||||
services: Sparkles,
|
|
||||||
tuktuk: TukTuk,
|
|
||||||
taxi: Car,
|
|
||||||
lancha: Ship,
|
|
||||||
other: HelpCircle,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
tuktuk: 'text-amber-500',
|
|
||||||
restaurant: 'text-orange-500',
|
|
||||||
lodging: 'text-purple-500',
|
|
||||||
goods: 'text-green-500',
|
|
||||||
services: 'text-pink-500',
|
|
||||||
taxi: 'text-yellow-500',
|
|
||||||
lancha: 'text-blue-500',
|
|
||||||
other: 'text-gray-500',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const towns = computed(() => [
|
|
||||||
{ id: 'all', label: t('directory.towns.all') },
|
|
||||||
{ id: 'San Marcos', label: 'San Marcos' },
|
|
||||||
{ id: 'San Pedro', label: 'San Pedro' },
|
|
||||||
{ id: 'Tzununa', label: 'Tzununa' },
|
|
||||||
{ id: 'Jaibalito', label: 'Jaibalito' },
|
|
||||||
{ id: 'San Pablo', label: 'San Pablo' },
|
|
||||||
{ id: 'Panajachel', label: 'Panajachel' },
|
|
||||||
])
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container mx-auto px-4 py-8 sm:py-12">
|
|
||||||
<div class="max-w-3xl mx-auto text-center space-y-8 sm:space-y-12">
|
|
||||||
<!-- Hero Section -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h1 class="text-4xl font-bold tracking-tight sm:text-6xl text-foreground animate-in fade-in slide-in-from-bottom-4 duration-1000 fill-mode-both">
|
|
||||||
{{ t('home.title') }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p class="text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-150 fill-mode-both">
|
|
||||||
{{ t('home.subtitle') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Town Selector -->
|
|
||||||
<div class="flex flex-col items-center gap-3 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300 fill-mode-both">
|
|
||||||
<h2 class="text-base font-medium text-muted-foreground">{{ t('home.selectTown') }}</h2>
|
|
||||||
<Select :model-value="currentTown" @update:model-value="setCurrentTown">
|
|
||||||
<SelectTrigger class="w-[240px] h-11">
|
|
||||||
<SelectValue :placeholder="t('home.selectTownPlaceholder')" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem v-for="town in towns" :key="town.id" :value="town.id">
|
|
||||||
{{ town.label }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category Buttons -->
|
|
||||||
<div class="relative mx-auto max-w-2xl pt-4 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-500 fill-mode-both">
|
|
||||||
<!-- Gradient Fade Edges -->
|
|
||||||
<div class="absolute left-0 top-0 bottom-0 w-4 bg-gradient-to-r from-background to-transparent z-10 sm:hidden"></div>
|
|
||||||
<div class="absolute right-0 top-0 bottom-0 w-4 bg-gradient-to-l from-background to-transparent z-10 sm:hidden"></div>
|
|
||||||
|
|
||||||
<!-- Scrollable Container -->
|
|
||||||
<div class="flex overflow-x-auto gap-2 px-4 pb-4
|
|
||||||
[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]
|
|
||||||
sm:flex-wrap sm:justify-center sm:gap-4 sm:px-0">
|
|
||||||
<router-link v-for="category in categories" :key="category"
|
|
||||||
:to="`/directory?category=${category}&town=${currentTown}`"
|
|
||||||
class="flex-shrink-0 w-[100px] sm:w-[110px]">
|
|
||||||
<Button variant="ghost" size="default"
|
|
||||||
class="w-full h-20 sm:h-24 flex flex-col items-center justify-center gap-1.5 sm:gap-2.5
|
|
||||||
hover:bg-accent hover:scale-105 active:scale-100 transition-all duration-200 group">
|
|
||||||
<component :is="categoryIcons[category]"
|
|
||||||
class="h-6 w-6 sm:h-7 sm:w-7 transition-transform duration-200 group-hover:scale-110"
|
|
||||||
:class="categoryColors[category]" />
|
|
||||||
<span class="text-xs sm:text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
|
||||||
{{ t(`directory.categories.${category}`) }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center pt-4 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-700 fill-mode-both">
|
|
||||||
<router-link to="/directory" class="sm:w-[160px]">
|
|
||||||
<Button variant="default" size="lg" class="w-full font-medium">
|
|
||||||
{{ t('home.browse') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<router-link to="/faq" class="sm:w-[160px]">
|
|
||||||
<Button variant="outline" size="lg" class="w-full font-medium">
|
|
||||||
{{ t('home.learnMore') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
|
||||||
import SupportChat from '@/components/SupportChat.vue'
|
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
|
||||||
import Login from '@/components/Login.vue'
|
|
||||||
|
|
||||||
const nostrStore = useNostrStore()
|
|
||||||
const showLoginDialog = ref(!nostrStore.isLoggedIn)
|
|
||||||
|
|
||||||
const handleLoginSuccess = () => {
|
|
||||||
showLoginDialog.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container max-w-4xl mx-auto h-[calc(100vh-4rem)] py-4 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="h-full">
|
|
||||||
<div class="h-full animate-in fade-in-50 slide-in-from-bottom-3">
|
|
||||||
<SupportChat v-if="nostrStore.isLoggedIn" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Dialog -->
|
|
||||||
<Dialog v-model:open="showLoginDialog">
|
|
||||||
<DialogContent class="sm:max-w-md">
|
|
||||||
<Login @success="handleLoginSuccess" />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.animate-in {
|
|
||||||
animation-duration: 0.3s;
|
|
||||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
animation-fill-mode: forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in-50 {
|
|
||||||
animation-name: fade-in-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-in-from-bottom-3 {
|
|
||||||
animation-name: slide-in-from-bottom-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in-50 {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-in-from-bottom-3 {
|
|
||||||
from {
|
|
||||||
transform: translateY(3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import Home from '@/pages/Home.vue'
|
import Home from '@/pages/Home.vue'
|
||||||
import Directory from '@/pages/Directory.vue'
|
|
||||||
import FAQ from '@/pages/FAQ.vue'
|
|
||||||
import Support from '@/pages/Support.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
|
@ -12,27 +9,6 @@ const router = createRouter({
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: Home
|
component: Home
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/directory',
|
|
||||||
name: 'directory',
|
|
||||||
component: Directory
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/faq',
|
|
||||||
name: 'faq',
|
|
||||||
component: FAQ
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/directory/:id',
|
|
||||||
name: 'directory-item',
|
|
||||||
component: () => import('@/pages/DirectoryItem.vue'),
|
|
||||||
props: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/support',
|
|
||||||
name: 'support',
|
|
||||||
component: Support
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import type { DirectMessage } from '@/types/nostr'
|
|
||||||
|
|
||||||
// Separate message handling into its own store
|
|
||||||
export const useMessageStore = defineStore('messages', () => {
|
|
||||||
const messages = ref<Map<string, DirectMessage[]>>(new Map())
|
|
||||||
const processedIds = ref(new Set<string>())
|
|
||||||
|
|
||||||
const addMessage = async (pubkey: string, message: DirectMessage) => {
|
|
||||||
if (processedIds.value.has(message.id)) return
|
|
||||||
|
|
||||||
processedIds.value.add(message.id)
|
|
||||||
const userMessages = messages.value.get(pubkey) || []
|
|
||||||
messages.value.set(pubkey, [...userMessages, message].sort((a, b) =>
|
|
||||||
a.created_at - b.created_at
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
processedIds,
|
|
||||||
addMessage
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,621 +0,0 @@
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import type { NostrEvent, NostrProfile, NostrAccount, DirectMessage } from '../types/nostr'
|
|
||||||
import { isValidPrivateKey, formatPrivateKey } from '@/lib/nostr'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
NostrTools: {
|
|
||||||
getPublicKey: (privkey: string) => string
|
|
||||||
generatePrivateKey: () => string
|
|
||||||
nip04: {
|
|
||||||
encrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
|
||||||
decrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
|
||||||
}
|
|
||||||
getEventHash: (event: NostrEvent) => string
|
|
||||||
signEvent: (event: NostrEvent, privkey: string) => Promise<string>
|
|
||||||
getSignature: (event: NostrEvent, privkey: string) => string
|
|
||||||
verifySignature: (event: NostrEvent) => boolean
|
|
||||||
nip19: {
|
|
||||||
decode: (str: string) => { type: string; data: string }
|
|
||||||
npubEncode: (hex: string) => string
|
|
||||||
}
|
|
||||||
relayInit: (url: string) => {
|
|
||||||
connect: () => Promise<void>
|
|
||||||
sub: (filters: any[]) => {
|
|
||||||
on: (event: string, callback: (event: NostrEvent) => void) => void
|
|
||||||
}
|
|
||||||
publish: (event: NostrEvent) => {
|
|
||||||
on: (type: 'ok' | 'failed', cb: (msg?: string) => void) => void
|
|
||||||
}
|
|
||||||
close: () => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_RELAYS = [
|
|
||||||
'wss://nostr.atitlan.io'
|
|
||||||
]
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number = 10000): Promise<T> {
|
|
||||||
return Promise.race([
|
|
||||||
promise,
|
|
||||||
new Promise<T>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Operation timed out')), timeoutMs)
|
|
||||||
)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to state
|
|
||||||
const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected')
|
|
||||||
const hasUnreadMessages = ref(false)
|
|
||||||
const viewedMessageIds = ref<Set<string>>(new Set(
|
|
||||||
JSON.parse(localStorage.getItem('nostr_viewed_messages') || '[]')
|
|
||||||
))
|
|
||||||
|
|
||||||
// Update in connect function
|
|
||||||
async function connectToRelay(url: string) {
|
|
||||||
connectionStatus.value = 'connecting'
|
|
||||||
console.log(`Attempting to connect to relay: ${url}`)
|
|
||||||
const relay = window.NostrTools.relayInit(url)
|
|
||||||
try {
|
|
||||||
console.log(`Initializing connection to ${url}...`)
|
|
||||||
await withTimeout(relay.connect())
|
|
||||||
console.log(`Successfully connected to ${url}`)
|
|
||||||
connectionStatus.value = 'connected'
|
|
||||||
return relay
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to connect to ${url}:`, err)
|
|
||||||
connectionStatus.value = 'disconnected'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishEvent(event: NostrEvent, relays: { url: string }[]) {
|
|
||||||
const promises = relays.map(async ({ url }) => {
|
|
||||||
const relay = window.NostrTools.relayInit(url)
|
|
||||||
try {
|
|
||||||
await relay.connect()
|
|
||||||
const pub = relay.publish(event)
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
pub.on('ok', () => resolve(true))
|
|
||||||
pub.on('failed', reject)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to publish to ${url}:`, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all(promises)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useNostrStore = defineStore('nostr', () => {
|
|
||||||
// State
|
|
||||||
const account = ref<NostrAccount | null>(JSON.parse(localStorage.getItem('nostr_account') || 'null'))
|
|
||||||
const profiles = ref<Map<string, NostrProfile>>(new Map())
|
|
||||||
const messages = ref<Map<string, DirectMessage[]>>(new Map())
|
|
||||||
const activeChat = ref<string | null>(null)
|
|
||||||
const relayPool = ref<any[]>([])
|
|
||||||
const processedMessageIds = ref(new Set<string>())
|
|
||||||
const currentSubscription = ref<any | null>(null)
|
|
||||||
const hasActiveSubscription = ref(false)
|
|
||||||
|
|
||||||
// Load stored messages and IDs on initialization
|
|
||||||
const initializeFromStorage = () => {
|
|
||||||
try {
|
|
||||||
const messageMap = new Map<string, DirectMessage[]>(
|
|
||||||
JSON.parse(localStorage.getItem('nostr_messages') || '[]')
|
|
||||||
)
|
|
||||||
|
|
||||||
messageMap.forEach((msgs: DirectMessage[]) => {
|
|
||||||
msgs.forEach(msg => {
|
|
||||||
processedMessageIds.value.add(msg.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
messages.value = messageMap
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load stored messages:', err)
|
|
||||||
localStorage.removeItem('nostr_messages')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call initialization
|
|
||||||
initializeFromStorage()
|
|
||||||
|
|
||||||
// Watch account changes and persist to localStorage
|
|
||||||
watch(account, (newAccount) => {
|
|
||||||
if (newAccount) {
|
|
||||||
localStorage.setItem('nostr_account', JSON.stringify(newAccount))
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('nostr_account')
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Watch messages for changes and persist
|
|
||||||
watch(messages, (newMessages) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('nostr_messages',
|
|
||||||
JSON.stringify(Array.from(newMessages.entries()))
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save messages:', err)
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Initialize store if account exists in localStorage
|
|
||||||
if (account.value) {
|
|
||||||
console.log('Found existing account, initializing connection...')
|
|
||||||
init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const isLoggedIn = computed(() => !!account.value)
|
|
||||||
const currentMessages = computed(() =>
|
|
||||||
activeChat.value ? messages.value.get(activeChat.value) || [] : []
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize connection if account exists
|
|
||||||
async function init() {
|
|
||||||
if (!account.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Only clear profiles and processed IDs
|
|
||||||
profiles.value.clear()
|
|
||||||
processedMessageIds.value.clear()
|
|
||||||
|
|
||||||
// Connect to relays
|
|
||||||
relayPool.value = (await Promise.all(
|
|
||||||
account.value.relays.map(async relay => {
|
|
||||||
console.log('Connecting to relay:', relay.url)
|
|
||||||
const connection = await connectToRelay(relay.url)
|
|
||||||
if (!connection) {
|
|
||||||
console.error('Failed to connect to relay:', relay.url)
|
|
||||||
}
|
|
||||||
return connection
|
|
||||||
})
|
|
||||||
)).filter((relay): relay is any => relay !== null)
|
|
||||||
|
|
||||||
if (relayPool.value.length === 0) {
|
|
||||||
throw new Error('Failed to connect to any relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup visibility change handler
|
|
||||||
setupVisibilityHandler()
|
|
||||||
|
|
||||||
// Subscribe to messages in the background
|
|
||||||
subscribeToMessages().catch(err => {
|
|
||||||
console.error('Background subscription failed:', err)
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to initialize:', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async function login(privkey: string) {
|
|
||||||
if (!isValidPrivateKey(privkey)) {
|
|
||||||
throw new Error('Invalid private key')
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedKey = formatPrivateKey(privkey)
|
|
||||||
const pubkey = window.NostrTools.getPublicKey(formattedKey)
|
|
||||||
|
|
||||||
account.value = {
|
|
||||||
pubkey,
|
|
||||||
privkey: formattedKey,
|
|
||||||
relays: DEFAULT_RELAYS.map(url => ({ url, read: true, write: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize connection in the background
|
|
||||||
init().catch(err => {
|
|
||||||
console.error('Background initialization failed:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
account.value = null
|
|
||||||
relayPool.value.forEach(relay => relay.close())
|
|
||||||
relayPool.value = []
|
|
||||||
messages.value.clear()
|
|
||||||
profiles.value.clear()
|
|
||||||
processedMessageIds.value.clear()
|
|
||||||
activeChat.value = null
|
|
||||||
localStorage.removeItem('nostr_messages')
|
|
||||||
localStorage.removeItem('nostr_account')
|
|
||||||
hasUnreadMessages.value = false
|
|
||||||
localStorage.removeItem('nostr_unread_messages')
|
|
||||||
viewedMessageIds.value.clear()
|
|
||||||
localStorage.removeItem('nostr_viewed_messages')
|
|
||||||
}
|
|
||||||
|
|
||||||
const addMessage = async (pubkey: string, message: DirectMessage) => {
|
|
||||||
// Skip if we've already processed this message
|
|
||||||
if (processedMessageIds.value.has(message.id)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
processedMessageIds.value.add(message.id)
|
|
||||||
const userMessages = messages.value.get(pubkey) || []
|
|
||||||
|
|
||||||
// Check for duplicates
|
|
||||||
const isDuplicate = userMessages.some(msg =>
|
|
||||||
msg.content === message.content &&
|
|
||||||
Math.abs(msg.created_at - message.created_at) < 1
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isDuplicate) {
|
|
||||||
messages.value.set(pubkey, [...userMessages, message].sort((a, b) =>
|
|
||||||
a.created_at - b.created_at
|
|
||||||
))
|
|
||||||
|
|
||||||
// Only set unread if:
|
|
||||||
// 1. Message came from websocket (not storage)
|
|
||||||
// 2. Not from current chat
|
|
||||||
// 3. Not sent by us
|
|
||||||
if (!message.fromStorage && pubkey !== activeChat.value && !message.sent) {
|
|
||||||
console.log('New unread message received:', { pubkey, messageId: message.id })
|
|
||||||
hasUnreadMessages.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage(to: string, content: string) {
|
|
||||||
if (!account.value) return
|
|
||||||
|
|
||||||
const encrypted = await window.NostrTools.nip04.encrypt(account.value.privkey, to, content)
|
|
||||||
const event: NostrEvent = {
|
|
||||||
kind: 4,
|
|
||||||
pubkey: account.value.pubkey,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [['p', to]],
|
|
||||||
content: encrypted,
|
|
||||||
id: '',
|
|
||||||
sig: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
event.id = window.NostrTools.getEventHash(event)
|
|
||||||
event.sig = await window.NostrTools.signEvent(event, account.value.privkey)
|
|
||||||
|
|
||||||
// Add to local messages first
|
|
||||||
const dm: DirectMessage = {
|
|
||||||
id: event.id,
|
|
||||||
pubkey: to,
|
|
||||||
content,
|
|
||||||
created_at: event.created_at,
|
|
||||||
sent: true
|
|
||||||
}
|
|
||||||
|
|
||||||
await addMessage(to, dm)
|
|
||||||
|
|
||||||
// Then publish to relays
|
|
||||||
await publishEvent(event, account.value.relays)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function subscribeToMessages() {
|
|
||||||
if (!account.value || hasActiveSubscription.value) return
|
|
||||||
|
|
||||||
hasActiveSubscription.value = true
|
|
||||||
// Cleanup existing subscription
|
|
||||||
unsubscribeFromMessages()
|
|
||||||
|
|
||||||
// Get timestamp from 24 hours ago
|
|
||||||
const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60)
|
|
||||||
let hasReceivedMessages = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const subscribeToRelay = (relay: any) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const subs: any[] = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Setting up subscriptions for relay...')
|
|
||||||
|
|
||||||
// Subscribe to received messages
|
|
||||||
const receivedSub = relay.sub([{
|
|
||||||
kinds: [4],
|
|
||||||
'#p': [account.value!.pubkey],
|
|
||||||
since,
|
|
||||||
limit: 100 // Add limit to ensure we get historical messages
|
|
||||||
}])
|
|
||||||
subs.push(receivedSub)
|
|
||||||
|
|
||||||
// Subscribe to sent messages
|
|
||||||
const sentSub = relay.sub([{
|
|
||||||
kinds: [4],
|
|
||||||
authors: [account.value!.pubkey],
|
|
||||||
since,
|
|
||||||
limit: 100 // Add limit to ensure we get historical messages
|
|
||||||
}])
|
|
||||||
subs.push(sentSub)
|
|
||||||
|
|
||||||
// Handle received messages
|
|
||||||
receivedSub.on('event', async (event: NostrEvent) => {
|
|
||||||
hasReceivedMessages = true
|
|
||||||
try {
|
|
||||||
if (processedMessageIds.value.has(event.id)) return
|
|
||||||
|
|
||||||
const decrypted = await window.NostrTools.nip04.decrypt(
|
|
||||||
account.value!.privkey,
|
|
||||||
event.pubkey,
|
|
||||||
event.content
|
|
||||||
)
|
|
||||||
|
|
||||||
const dm: DirectMessage = {
|
|
||||||
id: event.id,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
content: decrypted,
|
|
||||||
created_at: event.created_at,
|
|
||||||
sent: false,
|
|
||||||
fromStorage: false // Mark as not from storage
|
|
||||||
}
|
|
||||||
|
|
||||||
await addMessage(event.pubkey, dm)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to decrypt received message:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle sent messages
|
|
||||||
sentSub.on('event', async (event: NostrEvent) => {
|
|
||||||
hasReceivedMessages = true
|
|
||||||
try {
|
|
||||||
if (processedMessageIds.value.has(event.id)) return
|
|
||||||
|
|
||||||
const targetPubkey = event.tags.find(tag => tag[0] === 'p')?.[1]
|
|
||||||
if (!targetPubkey) return
|
|
||||||
|
|
||||||
const decrypted = await window.NostrTools.nip04.decrypt(
|
|
||||||
account.value!.privkey,
|
|
||||||
targetPubkey,
|
|
||||||
event.content
|
|
||||||
)
|
|
||||||
|
|
||||||
const dm: DirectMessage = {
|
|
||||||
id: event.id,
|
|
||||||
pubkey: targetPubkey,
|
|
||||||
content: decrypted,
|
|
||||||
created_at: event.created_at,
|
|
||||||
sent: true
|
|
||||||
}
|
|
||||||
|
|
||||||
await addMessage(targetPubkey, dm)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to decrypt sent message:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle EOSE (End of Stored Events)
|
|
||||||
receivedSub.on('eose', () => {
|
|
||||||
console.log('Received EOSE for received messages')
|
|
||||||
if (!hasReceivedMessages) {
|
|
||||||
console.log('No messages received yet, keeping subscription open')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
sentSub.on('eose', () => {
|
|
||||||
console.log('Received EOSE for sent messages')
|
|
||||||
if (!hasReceivedMessages) {
|
|
||||||
console.log('No messages received yet, keeping subscription open')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Store subscriptions for cleanup
|
|
||||||
currentSubscription.value = {
|
|
||||||
unsub: () => {
|
|
||||||
subs.forEach(sub => {
|
|
||||||
try {
|
|
||||||
if (sub && typeof sub.unsub === 'function') {
|
|
||||||
sub.unsub()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.debug('Failed to unsubscribe:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep subscription open
|
|
||||||
resolve(true)
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.debug('Error in subscription setup:', err)
|
|
||||||
resolve(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all relays to set up subscriptions
|
|
||||||
const results = await Promise.all(
|
|
||||||
relayPool.value.map(relay => subscribeToRelay(relay))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Consider success if at least one relay worked
|
|
||||||
return results.some(result => result)
|
|
||||||
} catch (err) {
|
|
||||||
console.debug('Subscription process failed:', err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsubscribeFromMessages() {
|
|
||||||
if (currentSubscription.value && typeof currentSubscription.value.unsub === 'function') {
|
|
||||||
try {
|
|
||||||
currentSubscription.value.unsub()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to unsubscribe:', err)
|
|
||||||
}
|
|
||||||
currentSubscription.value = null
|
|
||||||
}
|
|
||||||
hasActiveSubscription.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProfiles() {
|
|
||||||
if (!account.value) return
|
|
||||||
|
|
||||||
const pubkeysToLoad = new Set<string>()
|
|
||||||
|
|
||||||
// Collect all unique pubkeys from messages
|
|
||||||
for (const [pubkey] of messages.value.entries()) {
|
|
||||||
if (!profiles.value.has(pubkey)) {
|
|
||||||
pubkeysToLoad.add(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pubkeysToLoad.size === 0) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filter = {
|
|
||||||
kinds: [0],
|
|
||||||
authors: Array.from(pubkeysToLoad)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadFromRelay = (relay: any) => {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const sub = relay.sub([filter])
|
|
||||||
|
|
||||||
sub.on('event', (event: NostrEvent) => {
|
|
||||||
try {
|
|
||||||
const profile = JSON.parse(event.content)
|
|
||||||
profiles.value.set(event.pubkey, {
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
name: profile.name,
|
|
||||||
picture: profile.picture,
|
|
||||||
about: profile.about,
|
|
||||||
nip05: profile.nip05
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse profile:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Resolve after receiving EOSE (End of Stored Events)
|
|
||||||
sub.on('eose', () => {
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set a timeout in case EOSE is not received
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve()
|
|
||||||
}, 5000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load profiles from all relays concurrently
|
|
||||||
await Promise.all(relayPool.value.map(relay => loadFromRelay(relay)))
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load profiles:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a reconnection function
|
|
||||||
async function reconnectToRelays() {
|
|
||||||
if (!account.value) return
|
|
||||||
|
|
||||||
console.log('Attempting to reconnect to relays...')
|
|
||||||
|
|
||||||
// Close existing connections
|
|
||||||
relayPool.value.forEach(relay => {
|
|
||||||
try {
|
|
||||||
relay.close()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error closing relay:', err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
relayPool.value = []
|
|
||||||
|
|
||||||
// Reconnect
|
|
||||||
relayPool.value = (await Promise.all(
|
|
||||||
account.value.relays.map(async relay => {
|
|
||||||
console.log('Reconnecting to relay:', relay.url)
|
|
||||||
const connection = await connectToRelay(relay.url)
|
|
||||||
if (!connection) {
|
|
||||||
console.error('Failed to reconnect to relay:', relay.url)
|
|
||||||
}
|
|
||||||
return connection
|
|
||||||
})
|
|
||||||
)).filter((relay): relay is any => relay !== null)
|
|
||||||
|
|
||||||
if (relayPool.value.length === 0) {
|
|
||||||
throw new Error('Failed to connect to any relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resubscribe to messages
|
|
||||||
await subscribeToMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update visibility handler
|
|
||||||
function setupVisibilityHandler() {
|
|
||||||
const handleVisibilityChange = async () => {
|
|
||||||
if (document.visibilityState === 'visible' && account.value) {
|
|
||||||
console.log('Page became visible, checking connection...')
|
|
||||||
try {
|
|
||||||
// Only reconnect if we don't have active connections
|
|
||||||
if (relayPool.value.length === 0 || !hasActiveSubscription.value) {
|
|
||||||
await reconnectToRelays()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to reconnect:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any existing handler
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
|
|
||||||
// Add focus handler for mobile
|
|
||||||
window.addEventListener('focus', handleVisibilityChange)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add function to clear unread state
|
|
||||||
function clearUnreadMessages() {
|
|
||||||
hasUnreadMessages.value = false
|
|
||||||
localStorage.setItem('nostr_unread_messages', 'false')
|
|
||||||
|
|
||||||
// Mark all current chat messages as viewed
|
|
||||||
if (activeChat.value) {
|
|
||||||
const chatMessages = messages.value.get(activeChat.value) || []
|
|
||||||
chatMessages.forEach(msg => {
|
|
||||||
viewedMessageIds.value.add(msg.id)
|
|
||||||
})
|
|
||||||
// Persist viewed message IDs
|
|
||||||
localStorage.setItem('nostr_viewed_messages',
|
|
||||||
JSON.stringify(Array.from(viewedMessageIds.value))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to watch section
|
|
||||||
watch(activeChat, () => {
|
|
||||||
// Clear unread messages when changing to a chat
|
|
||||||
clearUnreadMessages()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
account,
|
|
||||||
profiles,
|
|
||||||
messages,
|
|
||||||
activeChat,
|
|
||||||
isLoggedIn,
|
|
||||||
currentMessages,
|
|
||||||
init,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
sendMessage,
|
|
||||||
subscribeToMessages,
|
|
||||||
unsubscribeFromMessages,
|
|
||||||
loadProfiles,
|
|
||||||
connectionStatus,
|
|
||||||
hasActiveSubscription,
|
|
||||||
hasUnreadMessages,
|
|
||||||
clearUnreadMessages,
|
|
||||||
reconnectToRelays,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
export interface DirectoryItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
category: 'restaurant' | 'lodging' | 'goods' | 'services' | 'tuktuk' | 'taxi' | 'lancha' | 'other'
|
|
||||||
local?: boolean
|
|
||||||
description?: string
|
|
||||||
url?: string
|
|
||||||
address?: string
|
|
||||||
town?: string
|
|
||||||
mapsUrl?: string
|
|
||||||
contact?: string
|
|
||||||
contactType?: ('whatsapp' | 'telegram')[]
|
|
||||||
lightning?: string
|
|
||||||
coordinates?: {
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
}
|
|
||||||
imageUrl?: string
|
|
||||||
social?: {
|
|
||||||
facebook?: string
|
|
||||||
instagram?: string
|
|
||||||
twitter?: string
|
|
||||||
youtube?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
export interface NostrEvent {
|
|
||||||
kind: number
|
|
||||||
pubkey: string
|
|
||||||
content: string
|
|
||||||
tags: string[][]
|
|
||||||
created_at: number
|
|
||||||
id: string
|
|
||||||
sig: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrProfile {
|
|
||||||
pubkey: string
|
|
||||||
name?: string
|
|
||||||
picture?: string
|
|
||||||
about?: string
|
|
||||||
nip05?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrRelayConfig {
|
|
||||||
url: string
|
|
||||||
read?: boolean
|
|
||||||
write?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrAccount {
|
|
||||||
pubkey: string
|
|
||||||
privkey: string
|
|
||||||
relays: NostrRelayConfig[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectMessage {
|
|
||||||
id: string
|
|
||||||
pubkey: string
|
|
||||||
content: string
|
|
||||||
created_at: number
|
|
||||||
sent: boolean
|
|
||||||
fromStorage?: boolean
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue