feat: Implement enhanced user profiles with social features including direct messaging, post comments, and reposts, and introduce new routing for Docs and Changelog views.

This commit is contained in:
2026-01-18 13:10:12 +01:00
parent 959b453d69
commit 62280265b4
23 changed files with 1826 additions and 458 deletions

View File

@@ -11,13 +11,17 @@ server {
# Proxy API requests to backend
location /socket.io/ {
proxy_pass http://server:3000;
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /api/ {
proxy_pass http://server:3000;
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

View File

@@ -15,7 +15,8 @@
"pinia": "^3.0.4",
"socket.io-client": "^4.8.3",
"tweetnacl": "^1.0.3",
"vue": "^3.5.24"
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -5849,6 +5850,27 @@
"eslint": ">=6.0.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@@ -17,7 +17,8 @@
"pinia": "^3.0.4",
"socket.io-client": "^4.8.3",
"tweetnacl": "^1.0.3",
"vue": "^3.5.24"
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -31,4 +32,4 @@
"vite": "^7.2.4",
"vitest": "^4.0.17"
}
}
}

View File

@@ -1,8 +1,6 @@
<script setup>
import { ref } from 'vue';
import { useChatStore } from './stores/chat';
import WalletConnect from './components/WalletConnect.vue';
import ChatLayout from './components/ChatLayout.vue';
const chatStore = useChatStore();
const videoRef = ref(null);
@@ -34,11 +32,7 @@ const handleMuteToggle = () => {
<!-- Content -->
<div class="relative z-10 h-full">
<WalletConnect v-if="!chatStore.isConnected" />
<ChatLayout
v-else
@toggle-mute="handleMuteToggle"
/>
<router-view @toggle-mute="handleMuteToggle" />
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<template>
<div class="changelog-container p-6 bg-slate-900/50 rounded-xl border border-slate-700/50 backdrop-blur-md overflow-y-auto max-h-[80vh]">
<h1 class="text-3xl font-bold mb-8 text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-400">
Mission Log: Updates
</h1>
<div class="space-y-12">
<!-- Latest Version -->
<section>
<div class="flex items-center gap-4 mb-4">
<span class="px-3 py-1 bg-emerald-500/20 text-emerald-400 rounded-full text-xs font-mono font-bold border border-emerald-500/30">v1.2.0</span>
<span class="text-slate-500 text-sm">Jan 18, 2026</span>
</div>
<h2 class="text-xl font-bold text-slate-100 mb-4">The "Alpha Engine" Update</h2>
<ul class="space-y-3">
<li class="flex gap-3 text-slate-300">
<span class="text-emerald-500 font-bold"></span>
<div>
<p class="font-medium text-slate-200">Refined AI Insights</p>
<p class="text-sm text-slate-400 text-pretty">Upgraded the summary engine with better formatting and structured alpha detection.</p>
</div>
</li>
<li class="flex gap-3 text-slate-300">
<span class="text-emerald-500 font-bold"></span>
<div>
<p class="font-medium text-slate-200">Social Persistence</p>
<p class="text-sm text-slate-400 text-pretty">Fixed profile display regressions and improved username update stability.</p>
</div>
</li>
<li class="flex gap-3 text-slate-300">
<span class="text-emerald-500 font-bold"></span>
<div>
<p class="font-medium text-slate-200">Docs & Changelog</p>
<p class="text-sm text-slate-400 text-pretty">Integrated user-friendly documentation and this mission log directly into the HUD.</p>
</div>
</li>
</ul>
</section>
<!-- Previous Version -->
<section class="opacity-70 grayscale-[0.5] hover:opacity-100 hover:grayscale-0 transition-all">
<div class="flex items-center gap-4 mb-4">
<span class="px-3 py-1 bg-slate-700/50 text-slate-400 rounded-full text-xs font-mono font-bold border border-slate-600/30">v1.1.0</span>
<span class="text-slate-500 text-sm">Jan 17, 2026</span>
</div>
<h2 class="text-xl font-bold text-slate-100 mb-4">Social Layer Genesis</h2>
<ul class="space-y-3">
<li class="flex gap-3 text-slate-300">
<span class="text-slate-500 font-bold"></span>
<div>
<p class="font-medium text-slate-200">User Profiles</p>
<p class="text-sm text-slate-400">Customize your bio and banner color on the new profile page.</p>
</div>
</li>
<li class="flex gap-3 text-slate-300">
<span class="text-slate-500 font-bold"></span>
<div>
<p class="font-medium text-slate-200">Repost (RT) System</p>
<p class="text-sm text-slate-400">Amplify high-signal posts across your profile feed.</p>
</div>
</li>
</ul>
</section>
</div>
</div>
</template>
<style scoped>
.changelog-container::-webkit-scrollbar {
width: 6px;
}
.changelog-container::-webkit-scrollbar-thumb {
background: rgba(16, 185, 129, 0.2);
border-radius: 10px;
}
</style>

View File

@@ -1,39 +1,3 @@
<script setup>
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import MessageList from './MessageList.vue';
import UserList from './UserList.vue';
import MusicPlayer from './MusicPlayer.vue';
import { Hash, Volume2, VolumeX, Settings, X, Menu, User } from 'lucide-vue-next';
import { ref } from 'vue';
const showProfile = ref(false);
const selectedProfileAddress = ref(null);
const showMobileMenu = ref(false);
const chatStore = useChatStore();
const { channels, currentChannel, username, walletAddress } = storeToRefs(chatStore);
const isMuted = ref(true);
const showSettings = ref(false);
const newUsername = ref(username.value);
const emit = defineEmits(['toggleMute']);
const toggleMute = () => {
isMuted.value = !isMuted.value;
emit('toggleMute', isMuted.value);
};
const saveSettings = () => {
if (newUsername.value.trim()) {
chatStore.username = newUsername.value;
// In a real app, we would emit a socket event to update the DB
chatStore.socket.emit('join', { walletAddress: walletAddress.value, username: newUsername.value });
showSettings.value = false;
}
};
</script>
<template>
<div class="flex h-screen w-full overflow-hidden relative bg-discord-dark">
<!-- Mobile Menu Overlay -->
@@ -43,6 +7,42 @@ const saveSettings = () => {
@click="showMobileMenu = false"
/>
<!-- Summary Modal -->
<div
v-if="showSummaryModal"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div class="bg-discord-sidebar border border-white/10 rounded-2xl w-full max-w-2xl shadow-2xl animate-pop-in flex flex-col max-h-[80vh]">
<div class="p-6 border-b border-white/5 flex items-center justify-between">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<Sparkles class="text-yellow-400" size="20" />
AI Channel Summary
</h2>
<button
class="text-gray-400 hover:text-white transition-colors"
@click="showSummaryModal = false"
>
<X size="24" />
</button>
</div>
<div class="p-8 overflow-y-auto custom-scrollbar bg-gradient-to-b from-transparent to-black/20">
<div v-if="isSummarizing" class="flex flex-col items-center justify-center py-20">
<div class="relative mb-6">
<div class="absolute inset-0 bg-violet-500/20 blur-2xl rounded-full scale-150 animate-pulse" />
<Sparkles class="text-violet-400 animate-spin relative z-10" size="48" />
</div>
<p class="text-gray-200 font-bold mb-2">Analyzing the alpha...</p>
<p class="text-gray-500 text-sm animate-pulse">Plexus AI is scanning #{{ currentChannel }} for the most relevant insights.</p>
</div>
<div v-else class="max-w-none">
<div class="summary-content whitespace-pre-wrap text-gray-200 leading-relaxed font-sans prose prose-invert prose-headings:text-white prose-headings:font-bold prose-headings:mb-4 prose-headings:mt-8 prose-p:mb-4 prose-li:mb-2 prose-strong:text-violet-400">
{{ summary }}
</div>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div
v-if="showSettings"
@@ -70,6 +70,16 @@ const saveSettings = () => {
placeholder="Enter new username"
>
</div>
<div>
<label class="block text-xs font-bold text-crypto-muted uppercase tracking-wider mb-2">NFT Profile Picture (URL)</label>
<input
v-model="nftProfilePic"
type="text"
class="w-full bg-discord-black border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all"
placeholder="https://..."
>
<p class="text-xs text-gray-500 mt-1">Paste your NFT image URL</p>
</div>
<div>
<label class="block text-xs font-bold text-crypto-muted uppercase tracking-wider mb-2">Wallet Address</label>
<div class="w-full bg-discord-black/50 border border-white/5 rounded-xl px-4 py-3 text-gray-500 text-sm truncate">
@@ -105,19 +115,6 @@ const saveSettings = () => {
<h1 class="font-bold text-white truncate">
Plexus Server
</h1>
<button
class="text-gray-400 hover:text-gray-200 transition-colors"
@click="toggleMute"
>
<VolumeX
v-if="isMuted"
size="18"
/>
<Volume2
v-else
size="18"
/>
</button>
</div>
<div class="flex-1 overflow-y-auto py-3 space-y-0.5 px-2">
@@ -125,7 +122,7 @@ const saveSettings = () => {
<button
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group mb-4',
showProfile && selectedProfileAddress === walletAddress ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
@click="selectedProfileAddress = walletAddress; showProfile = true; showMobileMenu = false"
@click="router.push(`/profile/${walletAddress}`)"
>
<User
size="18"
@@ -134,17 +131,37 @@ const saveSettings = () => {
<span class="text-sm font-medium">My Profile</span>
</button>
<div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider mt-4">
Documentation
</div>
<button
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group',
viewDocs ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
@click="router.push('/docs')"
>
<Book size="18" class="text-emerald-400" />
<span class="text-sm font-medium">User Guide</span>
</button>
<button
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group mb-4',
viewChangelog ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
@click="router.push('/changelog')"
>
<Terminal size="18" class="text-amber-400" />
<span class="text-sm font-medium">Changelog</span>
</button>
<div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
Text Channels
</div>
<div
v-for="channel in channels"
v-for="channel in textChannels"
:key="channel.id"
>
<button
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group',
currentChannel === channel.id && !showProfile ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
@click="chatStore.setChannel(channel.id); showProfile = false; showMobileMenu = false"
@click="navigateToChannel(channel.id)"
>
<Hash
size="18"
@@ -153,23 +170,54 @@ const saveSettings = () => {
<span class="text-sm font-medium">{{ channel.name }}</span>
</button>
</div>
<!-- Direct Messages -->
<div v-if="dmChannels.length > 0" class="mt-6">
<div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
Direct Messages
</div>
<div
v-for="channel in dmChannels"
:key="channel.id"
>
<button
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group',
currentChannel === channel.id && !showProfile ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
@click="navigateToChannel(channel.id)"
>
<MessageSquare
size="18"
:class="currentChannel === channel.id && !showProfile ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'"
/>
<span class="text-sm font-medium">{{ channel.name }}</span>
</button>
</div>
</div>
</div>
<!-- Music Player & Profile -->
<div class="bg-discord-black p-2 space-y-2">
<MusicPlayer />
<MusicPlayer :channel="currentChannel" />
<div
class="flex items-center gap-2 p-1.5 rounded-md hover:bg-[#35373c] transition-all group cursor-pointer"
@click="showSettings = true"
>
<div class="relative">
<div class="w-8 h-8 rounded-full bg-violet-600 flex items-center justify-center text-white text-xs font-bold">
<div class="flex items-center gap-2 p-1.5 rounded-md hover:bg-[#35373c] transition-all group">
<div
class="relative cursor-pointer"
@click="showSettings = true"
>
<div
v-if="chatStore.profilePicture"
class="w-8 h-8 rounded-full bg-cover bg-center border border-white/10"
:style="{ backgroundImage: `url(${chatStore.profilePicture})` }"
/>
<div v-else class="w-8 h-8 rounded-full bg-violet-600 flex items-center justify-center text-white text-xs font-bold">
{{ username?.substring(0, 2).toUpperCase() }}
</div>
<div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-discord-black rounded-full" />
</div>
<div class="flex-1 min-w-0">
<div
class="flex-1 min-w-0 cursor-pointer"
@click="showSettings = true"
>
<div class="text-xs font-bold text-white truncate">
{{ username }}
</div>
@@ -177,10 +225,13 @@ const saveSettings = () => {
#{{ walletAddress?.slice(-4) }} <span class="text-yellow-400">{{ chatStore.balance }} $PLEXUS</span>
</div>
</div>
<Settings
size="14"
class="text-gray-400 group-hover:text-gray-200"
/>
<button
class="text-gray-400 hover:text-red-400 transition-colors p-1"
title="Logout"
@click="logout"
>
<LogOut size="16" />
</button>
</div>
</div>
</div>
@@ -188,18 +239,52 @@ const saveSettings = () => {
<!-- Main Content -->
<div class="flex-1 flex flex-col bg-discord-dark relative overflow-hidden">
<!-- Header -->
<div class="h-12 px-4 flex items-center border-b border-black/20 shadow-sm bg-discord-dark/95 backdrop-blur-sm z-10">
<div class="h-12 px-4 flex items-center justify-between border-b border-black/20 shadow-sm bg-discord-dark/95 backdrop-blur-sm z-10">
<div class="flex items-center">
<button
class="md:hidden mr-3 text-gray-400 hover:text-white transition-colors"
@click="showMobileMenu = true"
>
<Menu size="24" />
</button>
<Hash
size="20"
class="text-gray-400 mr-2"
/>
<span class="font-bold text-white mr-4">{{ showProfile ? (selectedProfileAddress === walletAddress ? 'My Profile' : 'User Profile') : currentChannel }}</span>
<!-- Tabs (only when viewing chat channels, not docs/changelog/profile) -->
<div v-if="!showProfile && !viewDocs && !viewChangelog" class="hidden md:flex items-center gap-1 ml-4 bg-black/20 p-1 rounded-lg">
<button
:class="['px-3 py-1 text-xs font-bold rounded-md transition-all flex items-center gap-1.5', activeTab === 'chat' ? 'bg-white/10 text-white' : 'text-gray-400 hover:text-gray-200']"
@click="activeTab = 'chat'"
>
<Hash size="12" /> Chat
</button>
<button
:class="['px-3 py-1 text-xs font-bold rounded-md transition-all flex items-center gap-1.5', activeTab === 'rules' ? 'bg-white/10 text-white' : 'text-gray-400 hover:text-gray-200']"
@click="activeTab = 'rules'"
>
<FileText size="12" /> Rules
</button>
<button
:class="['px-3 py-1 text-xs font-bold rounded-md transition-all flex items-center gap-1.5', activeTab === 'governance' ? 'bg-white/10 text-white' : 'text-gray-400 hover:text-gray-200']"
@click="activeTab = 'governance'"
>
<Vote size="12" /> Governance
</button>
</div>
</div>
<!-- AI Summary Button (only in chat channels) -->
<button
class="md:hidden mr-3 text-gray-400 hover:text-white transition-colors"
@click="showMobileMenu = true"
v-if="!showProfile && !viewDocs && !viewChangelog && activeTab === 'chat'"
class="flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white text-xs font-bold rounded-lg transition-all shadow-lg shadow-violet-600/20"
@click="summarizeChannel"
>
<Menu size="24" />
<Sparkles size="14" />
AI Summary
</button>
<Hash
size="20"
class="text-gray-400 mr-2"
/>
<span class="font-bold text-white mr-4">{{ showProfile ? (selectedProfileAddress === walletAddress ? 'My Profile' : 'User Profile') : currentChannel }}</span>
</div>
<div class="flex-1 flex overflow-hidden">
@@ -208,17 +293,220 @@ const saveSettings = () => {
v-if="showProfile"
:address="selectedProfileAddress"
/>
<MessageList
v-else
@view-profile="(addr) => { selectedProfileAddress = addr; showProfile = true; }"
/>
<div v-else-if="viewDocs" class="flex-1 overflow-y-auto custom-scrollbar p-4 md:p-8 flex flex-col items-center">
<DocsView class="w-full max-w-4xl" />
</div>
<div v-else-if="viewChangelog" class="flex-1 overflow-y-auto custom-scrollbar p-4 md:p-8 flex flex-col items-center">
<ChangelogView class="w-full max-w-4xl" />
</div>
<template v-else>
<MessageList v-if="activeTab === 'chat'" @view-profile="navigateToProfile" />
<!-- Rules Tab -->
<div v-else-if="activeTab === 'rules'" class="flex-1 p-8 overflow-y-auto custom-scrollbar">
<div class="max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-white mb-6 flex items-center gap-3">
<FileText class="text-violet-400" /> Channel Rules
</h2>
<div class="bg-white/5 border border-white/10 rounded-2xl p-8 space-y-6">
<div class="flex gap-4">
<div class="w-8 h-8 rounded-full bg-violet-500/20 flex items-center justify-center text-violet-400 font-bold">1</div>
<div>
<h3 class="font-bold text-white mb-1">Be Respectful</h3>
<p class="text-gray-400">Treat everyone with respect. Harassment, hate speech, or abuse will not be tolerated.</p>
</div>
</div>
<div class="flex gap-4">
<div class="w-8 h-8 rounded-full bg-violet-500/20 flex items-center justify-center text-violet-400 font-bold">2</div>
<div>
<h3 class="font-bold text-white mb-1">No Spam</h3>
<p class="text-gray-400">Avoid excessive self-promotion or repetitive messages.</p>
</div>
</div>
<div class="flex gap-4">
<div class="w-8 h-8 rounded-full bg-violet-500/20 flex items-center justify-center text-violet-400 font-bold">3</div>
<div>
<h3 class="font-bold text-white mb-1">Stay On Topic</h3>
<p class="text-gray-400">Keep discussions relevant to the channel topic (#{{ currentChannel }}).</p>
</div>
</div>
</div>
</div>
</div>
<!-- Governance Tab -->
<div v-else-if="activeTab === 'governance'" class="flex-1 p-8 overflow-y-auto custom-scrollbar">
<div class="max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-white mb-6 flex items-center gap-3">
<Vote class="text-emerald-400" /> Governance
</h2>
<div class="grid gap-6">
<div class="bg-white/5 border border-white/10 rounded-2xl p-6">
<div class="flex justify-between items-start mb-4">
<div>
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs font-bold rounded uppercase">Active Vote</span>
<h3 class="text-xl font-bold text-white mt-2">Increase Channel Capacity</h3>
<p class="text-gray-400 text-sm mt-1">Proposal to increase max users per channel to 1000.</p>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-white">24h</div>
<div class="text-xs text-gray-500">Remaining</div>
</div>
</div>
<div class="space-y-3">
<div class="relative h-2 bg-white/10 rounded-full overflow-hidden">
<div class="absolute left-0 top-0 h-full bg-green-500 w-[75%]" />
</div>
<div class="flex justify-between text-sm">
<span class="text-green-400 font-bold">75% Yes</span>
<span class="text-red-400 font-bold">25% No</span>
</div>
</div>
<div class="mt-6 flex gap-3">
<button class="flex-1 py-2 bg-green-600/20 hover:bg-green-600/30 text-green-400 border border-green-600/50 rounded-lg font-bold transition-all">Vote Yes</button>
<button class="flex-1 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-600/50 rounded-lg font-bold transition-all">Vote No</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Member List (Discord Style) -->
<div class="w-60 bg-discord-sidebar border-l border-black/20 hidden xl:flex flex-col">
<UserList @view-profile="(addr) => { selectedProfileAddress = addr; showProfile = true; }" />
<UserList @view-profile="navigateToProfile" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import MessageList from './MessageList.vue';
import UserList from './UserList.vue';
import MusicPlayer from './MusicPlayer.vue';
import UserProfile from './UserProfile.vue';
import DocsView from './DocsView.vue';
import ChangelogView from './ChangelogView.vue';
import {
Hash,
Settings,
X,
Menu,
User,
LogOut,
Sparkles,
FileText,
Vote,
Book,
Terminal,
MessageSquare
} from 'lucide-vue-next';
const props = defineProps({
viewProfile: { type: Boolean, default: false },
profileAddress: { type: String, default: null },
viewDocs: { type: Boolean, default: false },
viewChangelog: { type: Boolean, default: false }
});
const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const { channels, currentChannel, username, walletAddress } = storeToRefs(chatStore);
const showProfile = ref(false);
const selectedProfileAddress = ref(null);
const showMobileMenu = ref(false);
const showSettings = ref(false);
const newUsername = ref(username.value);
const nftProfilePic = ref(chatStore.profilePicture || '');
const activeTab = ref('chat'); // chat, rules, governance
const isSummarizing = ref(false);
const summary = ref(null);
const showSummaryModal = ref(false);
const textChannels = computed(() => channels.value.filter(c => !c.id.startsWith('dm:')));
const dmChannels = computed(() => channels.value.filter(c => c.id.startsWith('dm:')));
const viewDocs = computed(() => props.viewDocs);
const viewChangelog = computed(() => props.viewChangelog);
// Sync from route
const syncFromRoute = () => {
if (route.name === 'Profile') {
showProfile.value = true;
selectedProfileAddress.value = route.params.address || walletAddress.value;
} else if (route.name === 'Docs') {
showProfile.value = false;
} else if (route.name === 'Changelog') {
showProfile.value = false;
} else if (route.name === 'Chat') {
showProfile.value = false;
const channelParam = route.params.channel;
if (channelParam && channelParam !== currentChannel.value) {
chatStore.setChannel(channelParam);
}
}
};
onMounted(syncFromRoute);
watch(() => route.fullPath, syncFromRoute);
// Navigate to channel
const navigateToChannel = (channelId) => {
router.push(`/chat/${channelId}`);
showProfile.value = false;
showMobileMenu.value = false;
activeTab.value = 'chat';
};
// Navigate to profile
const navigateToProfile = (address) => {
router.push(`/profile/${address}`);
showMobileMenu.value = false;
};
const saveSettings = () => {
if (newUsername.value.trim()) {
chatStore.updateUsername(newUsername.value);
}
if (nftProfilePic.value) {
chatStore.setProfilePicture(nftProfilePic.value);
}
showSettings.value = false;
};
const logout = () => {
chatStore.logout();
router.push('/login');
};
const summarizeChannel = async () => {
isSummarizing.value = true;
summary.value = null;
showSummaryModal.value = true;
try {
const response = await fetch('/api/summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId: currentChannel.value })
});
const data = await response.json();
if (data.error) throw new Error(data.error);
summary.value = data.summary;
} catch (e) {
console.error(e);
summary.value = "Failed to generate summary. Please try again later.";
} finally {
isSummarizing.value = false;
}
};
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="docs-container p-6 bg-slate-900/50 rounded-xl border border-slate-700/50 backdrop-blur-md overflow-y-auto max-h-[80vh]">
<h1 class="text-3xl font-bold mb-8 text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-400">
Welcome to Plexus
</h1>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-indigo-300">👋 Introduction</h2>
<p class="text-slate-300 leading-relaxed mb-4">
Plexus is a next-generation crypto-native social layer. Connect your wallet, chat in real-time, and earn $PLEXUS for your contributions.
</p>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-indigo-300"> The Rules of the Room</h2>
<ul class="list-disc list-inside text-slate-300 space-y-2">
<li>Be respectful to other degens.</li>
<li>No spam or repetitive shills in main channels.</li>
<li>Alpha is rewarded; noise is filtered.</li>
<li>Your wallet is your identity. Guard it well.</li>
</ul>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-indigo-300">💰 Token Economy</h2>
<p class="text-slate-300 mb-4">
Every user starts with <span class="text-indigo-400 font-mono">100 $PLEXUS</span>.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="p-4 bg-slate-800/50 rounded-lg border border-slate-700">
<h3 class="font-bold text-slate-100 mb-2">Earning</h3>
<p class="text-sm text-slate-400">Receive reactions (Coming soon), participate in discussions, and contribute alpha to boost your balance.</p>
</div>
<div class="p-4 bg-slate-800/50 rounded-lg border border-slate-700">
<h3 class="font-bold text-slate-100 mb-2">Spending</h3>
<p class="text-sm text-slate-400">Updating your username costs <span class="text-indigo-400 font-mono">30 $PLEXUS</span>. Quality costs, alpha pays.</p>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-xl font-semibold mb-4 text-indigo-300">🤖 AI Summaries</h2>
<p class="text-slate-300 leading-relaxed">
Feeling overwhelmed? Use the <span class="font-bold text-indigo-400">AI Summary</span> button in any channel to get a high-signal brief of everything you missed.
</p>
</section>
<section>
<h2 class="text-xl font-semibold mb-4 text-indigo-300"> FAQ</h2>
<div class="space-y-4">
<div>
<h3 class="text-slate-100 font-medium">How do I change my profile?</h3>
<p class="text-slate-400 text-sm">Head to "My Profile" in the sidebar to update your bio and banner color.</p>
</div>
<div>
<h3 class="text-slate-100 font-medium">Why did my message turn red?</h3>
<p class="text-slate-400 text-sm">A red status indicates a transaction failure or connection issue. Try refreshing!</p>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.docs-container::-webkit-scrollbar {
width: 6px;
}
.docs-container::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.2);
border-radius: 10px;
}
</style>

View File

@@ -2,17 +2,19 @@
import { ref, onUpdated, nextTick } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { Send, Hash, Smile } from 'lucide-vue-next';
import { Send, Hash, Smile, ExternalLink, Copy, Check } from 'lucide-vue-next';
const chatStore = useChatStore();
const { currentMessages, currentChannel, walletAddress } = storeToRefs(chatStore);
const newMessage = ref('');
const messagesContainer = ref(null);
const showEmojiPicker = ref(null); // messageId
const showEmojiPicker = ref(null);
const copiedTxId = ref(null);
const EMOJIS = ['👍', '❤️', '🔥', '😂', '😮', '😢'];
const EMOJIS = ['👍', '❤️', '🔥', '😂', '😮', '😢', '🚀', '💎'];
const toggleReaction = (messageId, emoji) => {
console.log('Toggling reaction:', messageId, emoji);
chatStore.toggleReaction(messageId, emoji);
showEmojiPicker.value = null;
};
@@ -51,18 +53,21 @@ const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const copyTxId = (txId) => {
navigator.clipboard.writeText(txId);
copiedTxId.value = txId;
setTimeout(() => { copiedTxId.value = null; }, 2000);
};
const emit = defineEmits(['view-profile']);
</script>
<template>
<div class="flex-1 flex flex-col h-full bg-discord-dark relative z-10">
<!-- Header (Desktop only, mobile header is in ChatLayout) -->
<!-- Header (Desktop only) -->
<div class="hidden md:flex h-12 border-b border-black/20 items-center px-4 shadow-sm bg-discord-dark/95 backdrop-blur-sm">
<div class="text-sm font-bold text-white flex items-center gap-2">
<Hash
size="18"
class="text-gray-400"
/>
<Hash size="18" class="text-gray-400" />
{{ currentChannel }}
</div>
</div>
@@ -88,9 +93,12 @@ const emit = defineEmits(['view-profile']);
<div
v-for="(msg, index) in currentMessages"
:key="msg.id || msg.tempId"
class="group flex gap-4 px-4 py-1 hover:bg-white/[0.02] transition-colors relative"
:class="[
'group flex gap-4 px-4 py-2 transition-all relative rounded-lg',
msg.status === 'failed' ? 'bg-red-500/5 border border-red-500/20' : 'hover:bg-white/[0.02]'
]"
>
<!-- Avatar (only if first message in group) -->
<!-- Avatar -->
<div class="w-10 flex-shrink-0">
<div
v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
@@ -112,7 +120,7 @@ const emit = defineEmits(['view-profile']);
<div class="flex-1 min-w-0">
<div
v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
class="flex items-center gap-2 mb-0.5"
class="flex items-center gap-2 mb-1 flex-wrap"
>
<span
:class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']"
@@ -122,35 +130,61 @@ const emit = defineEmits(['view-profile']);
</span>
<span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</span>
<!-- Status LED -->
<div
v-if="msg.status"
class="led ml-1"
:class="{
'led-orange': msg.status === 'pending',
'led-green': msg.status === 'validated',
'led-red': msg.status === 'failed'
}"
:title="msg.status"
/>
</div>
<div :class="['text-sm leading-relaxed break-words', msg.status === 'failed' ? 'text-status-failed line-through opacity-60' : 'text-gray-100']">
{{ msg.content }}
<!-- Message Content & Status -->
<div class="flex items-start gap-2">
<div :class="['text-sm leading-relaxed break-words flex-1', msg.status === 'failed' ? 'text-red-400 line-through' : 'text-gray-100']">
{{ msg.content }}
</div>
<!-- Transaction ID & Status Pill for all messages -->
<div class="flex items-center gap-2 flex-shrink-0 mt-1">
<span
v-if="msg.txId && msg.status !== 'failed'"
class="flex items-center gap-1.5 text-[10px] font-mono text-gray-500 bg-black/20 px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink size="10" />
{{ msg.txId.slice(0, 8) }}
<button
class="hover:text-fuchsia-400 transition-colors"
@click="copyTxId(msg.txId)"
>
<Check v-if="copiedTxId === msg.txId" size="10" class="text-green-400" />
<Copy v-else size="10" />
</button>
</span>
<div
v-if="msg.status"
class="led"
:class="{
'led-orange animate-pulse': msg.status === 'pending',
'led-green': msg.status === 'validated',
'led-red': msg.status === 'failed'
}"
/>
</div>
</div>
<!-- Failed message notice -->
<div v-if="msg.status === 'failed'" class="text-[10px] text-red-400 mt-1 flex items-center gap-1">
Transaction failed - message not saved
</div>
<!-- Reactions Display -->
<div
v-if="msg.reactions && msg.reactions.length > 0"
class="flex flex-wrap gap-1 mt-1.5"
class="flex flex-wrap gap-1 mt-2"
>
<button
v-for="emoji in getUniqueEmojis(msg.reactions)"
:key="emoji"
:class="['flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-xs border transition-all animate-pop-in',
:class="['flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-all',
hasUserReacted(msg.reactions, emoji)
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10']"
? 'bg-violet-500/20 border-violet-500/50 text-violet-300 shadow-sm shadow-violet-500/20'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:border-white/20']"
@click="toggleReaction(msg.id, emoji)"
>
<span>{{ emoji }}</span>
@@ -161,11 +195,11 @@ const emit = defineEmits(['view-profile']);
<!-- Hover Actions -->
<div
v-if="msg.status !== 'failed'"
class="absolute right-4 -top-4 opacity-0 group-hover:opacity-100 transition-opacity z-20 flex gap-1 bg-discord-sidebar border border-white/10 rounded-lg p-1 shadow-xl"
v-if="msg.status !== 'failed' && msg.id"
class="absolute right-4 -top-4 opacity-0 group-hover:opacity-100 transition-all z-20 flex gap-1 bg-discord-sidebar border border-white/10 rounded-xl p-1.5 shadow-2xl"
>
<button
class="p-1.5 hover:bg-white/10 rounded text-gray-400 hover:text-white transition-all"
class="p-2 hover:bg-white/10 rounded-lg text-gray-400 hover:text-white transition-all"
title="Add Reaction"
@click="showEmojiPicker = showEmojiPicker === msg.id ? null : msg.id"
>
@@ -180,7 +214,7 @@ const emit = defineEmits(['view-profile']);
<button
v-for="emoji in EMOJIS"
:key="emoji"
class="hover:scale-125 transition-transform p-1 text-lg"
class="hover:scale-125 transition-transform p-1.5 text-lg hover:bg-white/10 rounded-lg"
@click="toggleReaction(msg.id, emoji)"
>
{{ emoji }}
@@ -191,8 +225,8 @@ const emit = defineEmits(['view-profile']);
</div>
<!-- Input -->
<div class="p-4 bg-discord-dark">
<div class="relative bg-discord-sidebar/50 rounded-xl border border-white/5 p-1 transition-all focus-within:border-violet-500/30 focus-within:bg-discord-sidebar/80">
<div class="p-4 bg-discord-dark border-t border-white/5">
<div class="relative bg-discord-sidebar/50 rounded-xl border border-white/5 p-1 transition-all focus-within:border-violet-500/30 focus-within:bg-discord-sidebar/80 focus-within:shadow-lg focus-within:shadow-violet-500/5">
<input
v-model="newMessage"
type="text"
@@ -201,21 +235,21 @@ const emit = defineEmits(['view-profile']);
@keyup.enter="send"
>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-gray-400 hover:text-violet-400 transition-colors"
class="absolute right-2 top-1/2 -translate-y-1/2 p-2.5 text-gray-400 hover:text-violet-400 hover:bg-violet-600/10 rounded-lg transition-all"
@click="send"
>
<Send size="20" />
</button>
</div>
<div class="mt-2 flex items-center gap-4 px-1">
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-orange w-1.5 h-1.5" /> Pending
<div class="mt-3 flex items-center gap-6 px-1">
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-orange w-2 h-2 animate-pulse" /> Pending
</div>
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-green w-1.5 h-1.5" /> Validated
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-green w-2 h-2" /> Validated
</div>
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-red w-1.5 h-1.5" /> Failed
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-red w-2 h-2" /> Failed (not saved)
</div>
</div>
</div>
@@ -231,13 +265,4 @@ const emit = defineEmits(['view-profile']);
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.animate-fade-in-up {
animation: fade-in-up 0.2s ease-out;
}
@keyframes fade-in-up {
0% { transform: translateY(10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
</style>

View File

@@ -1,21 +1,37 @@
<script setup>
import { ref } from 'vue';
import { Play, Pause, SkipForward, Volume2, VolumeX, Music } from 'lucide-vue-next';
import { ref, watch, computed } from 'vue';
import { Play, Pause, SkipForward, Volume2, VolumeX, Music, Radio } from 'lucide-vue-next';
const props = defineProps({
channel: { type: String, default: 'nebula' }
});
const isPlaying = ref(false);
const isMuted = ref(false);
const volume = ref(50);
// A few lofi streams/videos
const LOFI_VIDEOS = [
'jfKfPfyJRdk', // Lofi Girl - beats to relax/study to
'5yx6BWbLrqY', // Chillhop - lofi hip hop radio
'7NOSDKb0Hqh', // Coffee Shop Radio
];
const currentVideoIndex = ref(0);
const player = ref(null);
// Channel-specific playlists
const CHANNEL_PLAYLISTS = {
nebula: { name: 'Lofi Beats', video: 'jfKfPfyJRdk', color: 'from-violet-500 to-purple-600' },
solstice: { name: 'Jazz Vibes', video: '5eLY-DYAWPk', color: 'from-amber-500 to-orange-600' },
zenith: { name: 'Synthwave', video: 'MVPTGNGiI-4', color: 'from-cyan-500 to-blue-600' },
aether: { name: 'Ambient', video: 'S_MOd40zlYU', color: 'from-emerald-500 to-teal-600' },
vortex: { name: 'Electronic', video: '36YnV9STBqc', color: 'from-pink-500 to-rose-600' },
borealis: { name: 'Chillhop', video: '5qap5aO4i9A', color: 'from-sky-500 to-blue-600' },
chronos: { name: 'Classical', video: 'mIYzp5rcTvU', color: 'from-yellow-500 to-amber-600' },
elysium: { name: 'Nature Sounds', video: 'lCOF9LN_Zxs', color: 'from-green-500 to-emerald-600' },
ignis: { name: 'Rock Radio', video: 'n_GFN3a0yj0', color: 'from-red-500 to-orange-600' },
nova: { name: 'Deep House', video: 'jvipPYFebWc', color: 'from-fuchsia-500 to-purple-600' }
};
const currentPlaylist = computed(() => CHANNEL_PLAYLISTS[props.channel] || CHANNEL_PLAYLISTS.nebula);
// Watch for channel changes
watch(() => props.channel, () => {
isPlaying.value = false;
});
const togglePlay = () => {
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
@@ -25,10 +41,6 @@ const togglePlay = () => {
}
};
const nextTrack = () => {
currentVideoIndex.value = (currentVideoIndex.value + 1) % LOFI_VIDEOS.length;
};
const toggleMute = () => {
isMuted.value = !isMuted.value;
const vol = isMuted.value ? 0 : volume.value;
@@ -43,72 +55,56 @@ const updateVolume = () => {
</script>
<template>
<div class="p-4 bg-white/5 border border-white/10 rounded-2xl backdrop-blur-md shadow-xl">
<div class="flex items-center gap-4">
<!-- Hidden YouTube Player -->
<iframe
ref="player"
class="hidden"
:src="`https://www.youtube.com/embed/${LOFI_VIDEOS[currentVideoIndex]}?enablejsapi=1&autoplay=0&controls=0&disablekb=1&fs=0&modestbranding=1&iv_load_policy=3`"
frameborder="0"
/>
<div class="p-3 bg-white/5 border border-white/10 rounded-xl backdrop-blur-md">
<!-- Hidden YouTube Player -->
<iframe
ref="player"
class="hidden"
:src="`https://www.youtube.com/embed/${currentPlaylist.video}?enablejsapi=1&autoplay=0&controls=0&disablekb=1&fs=0&modestbranding=1&iv_load_policy=3`"
frameborder="0"
/>
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white shadow-lg animate-pulse-slow">
<Music size="24" />
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-lg flex items-center justify-center text-white shadow-lg"
:class="[`bg-gradient-to-br ${currentPlaylist.color}`, isPlaying ? 'animate-pulse' : '']"
>
<Radio size="20" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold text-white truncate">
Lofi Radio
<div class="text-xs font-bold text-white truncate">
{{ currentPlaylist.name }}
</div>
<div class="text-[10px] text-crypto-muted uppercase tracking-wider">
Chilling in the Nebula
#{{ channel }} Radio
</div>
</div>
<div class="flex items-center gap-2">
<button
class="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all"
@click="togglePlay"
>
<Pause
v-if="isPlaying"
size="18"
/>
<Play
v-else
size="18"
/>
</button>
<button
class="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-white transition-all"
@click="nextTrack"
>
<SkipForward size="18" />
</button>
</div>
<button
class="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all"
@click="togglePlay"
>
<Pause v-if="isPlaying" size="16" />
<Play v-else size="16" />
</button>
</div>
<div class="mt-4 flex items-center gap-3">
<!-- Volume Control -->
<div class="mt-3 flex items-center gap-2">
<button
class="text-gray-400 hover:text-white transition-colors"
@click="toggleMute"
>
<VolumeX
v-if="isMuted || volume == 0"
size="16"
/>
<Volume2
v-else
size="16"
/>
<VolumeX v-if="isMuted || volume == 0" size="14" />
<Volume2 v-else size="14" />
</button>
<input
v-model="volume"
type="range"
min="0"
max="100"
class="flex-1 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-indigo-500"
class="flex-1 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-violet-500"
@input="updateVolume"
>
</div>
@@ -118,9 +114,9 @@ const updateVolume = () => {
<style scoped>
input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: #6366f1;
width: 10px;
height: 10px;
background: #8b5cf6;
border-radius: 50%;
cursor: pointer;
}

View File

@@ -1,11 +1,17 @@
<script setup>
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { MessageSquare } from 'lucide-vue-next';
const chatStore = useChatStore();
const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
const { onlineUsers, offlineUsers, walletAddress } = storeToRefs(chatStore);
const emit = defineEmits(['view-profile']);
const startDM = (e, targetWallet) => {
e.stopPropagation(); // Prevent opening profile
chatStore.startDM(targetWallet);
};
</script>
<template>
@@ -20,7 +26,7 @@ const emit = defineEmits(['view-profile']);
<div
v-for="user in onlineUsers"
:key="user.wallet_address"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all relative"
@click="emit('view-profile', user.wallet_address)"
>
<div class="relative">
@@ -34,6 +40,16 @@ const emit = defineEmits(['view-profile']);
{{ user.username }}
</div>
</div>
<!-- Message Button (Hover) -->
<button
v-if="user.wallet_address !== walletAddress"
class="absolute right-2 p-1.5 bg-discord-black rounded-full text-gray-400 hover:text-white hover:bg-violet-600 opacity-0 group-hover:opacity-100 transition-all shadow-lg"
title="Message"
@click="(e) => startDM(e, user.wallet_address)"
>
<MessageSquare size="12" />
</button>
</div>
</div>
</div>
@@ -47,7 +63,7 @@ const emit = defineEmits(['view-profile']);
<div
v-for="user in offlineUsers"
:key="user.wallet_address"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all opacity-60 hover:opacity-100"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all opacity-60 hover:opacity-100 relative"
@click="emit('view-profile', user.wallet_address)"
>
<div class="relative">
@@ -61,6 +77,16 @@ const emit = defineEmits(['view-profile']);
{{ user.username }}
</div>
</div>
<!-- Message Button (Hover) -->
<button
v-if="user.wallet_address !== walletAddress"
class="absolute right-2 p-1.5 bg-discord-black rounded-full text-gray-400 hover:text-white hover:bg-violet-600 opacity-0 group-hover:opacity-100 transition-all shadow-lg"
title="Message"
@click="(e) => startDM(e, user.wallet_address)"
>
<MessageSquare size="12" />
</button>
</div>
</div>
</div>

View File

@@ -1,8 +1,8 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, watch, computed } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { MessageSquare, Calendar, Edit3, Send, X } from 'lucide-vue-next';
import { MessageSquare, Calendar, Edit3, Send, X, Wallet, Coins, Copy, Check, Repeat2 } from 'lucide-vue-next';
const props = defineProps({
address: {
@@ -12,12 +12,20 @@ const props = defineProps({
});
const chatStore = useChatStore();
const { profileUser, profilePosts, isProfileLoading, walletAddress } = storeToRefs(chatStore);
const { profileUser, profilePosts, profileReposts, isProfileLoading, walletAddress, balance } = storeToRefs(chatStore);
const isEditing = ref(false);
const editBio = ref('');
const editBannerColor = ref('');
const editUsername = ref('');
const newPostContent = ref('');
const copiedAddress = ref(false);
// Comments state
const activeCommentPostId = ref(null);
const newCommentContent = ref('');
const isOwnProfile = computed(() => profileUser.value?.wallet_address === walletAddress.value);
const loadProfile = () => {
chatStore.getProfile(props.address);
@@ -29,10 +37,14 @@ watch(() => props.address, loadProfile);
const startEditing = () => {
editBio.value = profileUser.value.bio || '';
editBannerColor.value = profileUser.value.banner_color || '#6366f1';
editUsername.value = profileUser.value.username || '';
isEditing.value = true;
};
const saveProfile = () => {
if (editUsername.value && editUsername.value !== profileUser.value.username) {
chatStore.updateUsername(editUsername.value);
}
chatStore.updateProfile(editBio.value, editBannerColor.value);
isEditing.value = false;
};
@@ -43,10 +55,52 @@ const submitPost = () => {
newPostContent.value = '';
};
const toggleComments = (postId) => {
if (activeCommentPostId.value === postId) {
activeCommentPostId.value = null;
} else {
activeCommentPostId.value = postId;
chatStore.fetchComments(postId);
}
};
const submitComment = (postId) => {
if (!newCommentContent.value.trim()) return;
chatStore.createComment(postId, newCommentContent.value);
newCommentContent.value = '';
};
const repostPost = (postId) => {
chatStore.repost(postId);
};
const hasUserReposted = (post) => {
return post.reposted_by?.includes(walletAddress.value);
};
const startDM = () => {
if (profileUser.value?.wallet_address) {
chatStore.startDM(profileUser.value.wallet_address);
}
};
const formatTime = (isoString) => {
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const copyAddress = () => {
if (profileUser.value?.wallet_address) {
navigator.clipboard.writeText(profileUser.value.wallet_address);
copiedAddress.value = true;
setTimeout(() => { copiedAddress.value = false; }, 2000);
}
};
const shortenAddress = (addr) => {
if (!addr) return '';
return addr.slice(0, 6) + '...' + addr.slice(-4);
};
</script>
<template>
@@ -67,8 +121,9 @@ const formatTime = (isoString) => {
class="h-48 w-full relative transition-colors duration-500"
:style="{ backgroundColor: profileUser.banner_color }"
>
<div class="absolute inset-0 bg-gradient-to-t from-discord-dark/80 to-transparent" />
<div class="absolute -bottom-16 left-8">
<div class="w-32 h-32 rounded-full border-8 border-discord-dark bg-violet-600 flex items-center justify-center text-white text-4xl font-bold shadow-xl">
<div class="w-32 h-32 rounded-full border-8 border-discord-dark bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center text-white text-4xl font-bold shadow-2xl shadow-violet-600/30">
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
</div>
</div>
@@ -81,28 +136,82 @@ const formatTime = (isoString) => {
<h1 class="text-3xl font-bold text-white">
{{ profileUser.username }}
</h1>
<p class="text-gray-400 font-mono text-sm mt-1">
{{ profileUser.wallet_address }}
</p>
<button
class="flex items-center gap-2 text-gray-400 font-mono text-sm mt-1 hover:text-violet-400 transition-colors group"
@click="copyAddress"
>
<Wallet size="14" />
{{ shortenAddress(profileUser.wallet_address) }}
<Check v-if="copiedAddress" size="14" class="text-green-400" />
<Copy v-else size="14" class="opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</div>
<div class="flex gap-2">
<button
v-if="!isOwnProfile"
class="px-4 py-2 bg-violet-600 hover:bg-violet-500 rounded-full text-white text-sm font-medium transition-all flex items-center gap-2 shadow-lg shadow-violet-600/20"
@click="startDM"
>
<MessageSquare size="16" /> Message
</button>
<button
v-if="isOwnProfile"
class="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-white text-sm font-medium transition-all flex items-center gap-2"
@click="startEditing"
>
<Edit3 size="16" /> Edit Profile
</button>
</div>
<button
v-if="profileUser.wallet_address === walletAddress"
class="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-white text-sm font-medium transition-all flex items-center gap-2"
@click="startEditing"
>
<Edit3 size="16" /> Edit Profile
</button>
</div>
<div class="mt-4 text-gray-200 text-lg leading-relaxed max-w-2xl">
<!-- Stats Cards -->
<div class="mt-6 grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- Balance Card -->
<div class="bg-gradient-to-br from-yellow-500/10 to-orange-500/10 border border-yellow-500/20 rounded-xl p-4">
<div class="flex items-center gap-2 text-yellow-400 text-sm font-medium mb-1">
<Coins size="16" /> Balance
</div>
<div class="text-2xl font-bold text-white">
{{ isOwnProfile ? balance : '---' }}
<span class="text-sm text-yellow-400">$PLEXUS</span>
</div>
</div>
<!-- Posts Card -->
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<div class="flex items-center gap-2 text-violet-400 text-sm font-medium mb-1">
<MessageSquare size="16" /> Posts
</div>
<div class="text-2xl font-bold text-white">
{{ profilePosts?.length || 0 }}
</div>
</div>
<!-- Reposts Card -->
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<div class="flex items-center gap-2 text-emerald-400 text-sm font-medium mb-1">
<Repeat2 size="16" /> Reposts
</div>
<div class="text-2xl font-bold text-white">
{{ profilePosts?.reduce((acc, p) => acc + (p.repost_count || 0), 0) }}
</div>
</div>
<!-- Joined Card -->
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<div class="flex items-center gap-2 text-gray-400 text-sm font-medium mb-1">
<Calendar size="16" /> Joined
</div>
<div class="text-lg font-bold text-white">
{{ new Date(profileUser.last_seen).toLocaleDateString() }}
</div>
</div>
</div>
<!-- Bio -->
<div class="mt-6 text-gray-200 text-lg leading-relaxed max-w-2xl">
{{ profileUser.bio || 'No bio yet...' }}
</div>
<div class="mt-6 flex flex-wrap gap-4 text-gray-400 text-sm">
<div class="flex items-center gap-1.5">
<Calendar size="16" /> Joined {{ new Date(profileUser.last_seen).toLocaleDateString() }}
</div>
</div>
</div>
<!-- Wall / Posts -->
@@ -116,8 +225,8 @@ const formatTime = (isoString) => {
<!-- New Post Input (only for owner) -->
<div
v-if="profileUser.wallet_address === walletAddress"
class="mb-8 bg-discord-sidebar/30 rounded-2xl p-4 border border-white/5"
v-if="isOwnProfile"
class="mb-8 bg-discord-sidebar/30 rounded-2xl p-4 border border-white/5 focus-within:border-violet-500/30 transition-all"
>
<textarea
v-model="newPostContent"
@@ -126,7 +235,7 @@ const formatTime = (isoString) => {
/>
<div class="flex justify-end mt-2 pt-2 border-t border-white/5">
<button
class="px-6 py-2 bg-violet-600 hover:bg-violet-500 text-white rounded-full font-bold transition-all flex items-center gap-2 shadow-lg shadow-violet-600/20"
class="px-6 py-2 bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white rounded-full font-bold transition-all flex items-center gap-2 shadow-lg shadow-violet-600/20"
@click="submitPost"
>
Post <Send size="16" />
@@ -145,11 +254,11 @@ const formatTime = (isoString) => {
<div
v-for="post in profilePosts"
:key="post.id"
class="bg-discord-sidebar/20 rounded-2xl p-6 border border-white/5 hover:border-white/10 transition-all group"
class="bg-discord-sidebar/20 rounded-2xl p-6 border border-white/5 hover:border-violet-500/20 transition-all group"
>
<div class="flex justify-between items-start mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-violet-600/20 flex items-center justify-center text-violet-400 font-bold">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-violet-600/30 to-fuchsia-600/30 flex items-center justify-center text-violet-400 font-bold">
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
</div>
<div>
@@ -161,10 +270,103 @@ const formatTime = (isoString) => {
</div>
</div>
</div>
<button
:class="[
'transition-opacity p-2 rounded-lg flex items-center gap-1',
hasUserReposted(post)
? 'text-emerald-400 bg-emerald-500/10'
: 'opacity-0 group-hover:opacity-100 text-gray-400 hover:bg-white/5 hover:text-emerald-400'
]"
:title="hasUserReposted(post) ? 'Undo Repost' : 'Repost'"
@click="repostPost(post.id)"
>
<Repeat2 size="16" />
<span v-if="post.repost_count > 0" class="text-xs">{{ post.repost_count }}</span>
</button>
</div>
<div class="text-gray-200 leading-relaxed">
<div class="text-gray-200 leading-relaxed mb-4">
{{ post.content }}
</div>
<!-- Comments Section -->
<div class="border-t border-white/5 pt-4">
<button
class="text-xs font-bold text-gray-500 hover:text-violet-400 transition-colors flex items-center gap-2 mb-3"
@click="toggleComments(post.id)"
>
<MessageSquare size="14" />
{{ post.comment_count || post.comments?.length || 0 }} Comments
</button>
<div v-if="activeCommentPostId === post.id" class="space-y-3 animate-fade-in">
<!-- Existing Comments -->
<div v-if="post.comments && post.comments.length > 0" class="space-y-3 pl-4 border-l-2 border-white/5">
<div v-for="comment in post.comments" :key="comment.id" class="text-sm">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-white text-xs">{{ comment.username }}</span>
<span class="text-[10px] text-gray-500">{{ formatTime(comment.timestamp) }}</span>
</div>
<div class="text-gray-300">{{ comment.content }}</div>
</div>
</div>
<!-- Add Comment -->
<div class="flex gap-2 mt-3">
<input
v-model="newCommentContent"
type="text"
placeholder="Write a comment..."
class="flex-1 bg-discord-black/50 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50 transition-all"
@keyup.enter="submitComment(post.id)"
>
<button
class="p-2 bg-violet-600/20 hover:bg-violet-600/40 text-violet-400 rounded-lg transition-colors"
@click="submitComment(post.id)"
>
<Send size="14" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reposts Section -->
<div v-if="profileReposts.length > 0" class="p-8 max-w-3xl border-t border-white/5">
<h2 class="text-xl font-bold text-white mb-6 flex items-center gap-2">
<Repeat2
size="20"
class="text-emerald-400"
/> Reposts
</h2>
<div class="space-y-6">
<div
v-for="repost in profileReposts"
:key="repost.id"
class="bg-discord-sidebar/20 rounded-2xl p-6 border border-white/5 hover:border-emerald-500/20 transition-all"
>
<div class="flex items-center gap-2 text-xs text-emerald-400 mb-3">
<Repeat2 size="14" />
<span>{{ profileUser.username }} reposted</span>
</div>
<div class="flex items-center gap-3 mb-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-600/30 to-gray-700/30 flex items-center justify-center text-gray-400 font-bold text-sm">
{{ repost.original_username?.substring(0, 2).toUpperCase() }}
</div>
<div>
<div class="font-bold text-white text-sm">
{{ repost.original_username }}
</div>
<div class="text-[10px] text-gray-500">
{{ formatTime(repost.timestamp) }}
</div>
</div>
</div>
<div class="text-gray-200 leading-relaxed text-sm">
{{ repost.content }}
</div>
</div>
</div>
</div>
@@ -188,6 +390,15 @@ const formatTime = (isoString) => {
</button>
</div>
<div class="p-6 space-y-6">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Username (30 $PLEXUS)</label>
<input
v-model="editUsername"
type="text"
class="w-full bg-discord-black border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all font-bold mb-4"
placeholder="Username"
/>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bio</label>
<textarea

View File

@@ -1,7 +1,9 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useChatStore } from '../stores/chat';
const router = useRouter();
const chatStore = useChatStore();
const isConnecting = ref(false);
const error = ref(null);
@@ -26,6 +28,9 @@ const connectWallet = async () => {
// Simple username generation or prompt
const username = wallet.slice(0, 4) + '...' + wallet.slice(-4);
chatStore.connect(wallet, username, signature);
// Redirect to chat after successful login
router.push('/chat/nebula');
} else {
alert('Solana object not found! Get a Phantom Wallet 👻');
window.open('https://phantom.app/', '_blank');
@@ -41,29 +46,54 @@ const connectWallet = async () => {
<template>
<div class="flex flex-col items-center justify-center h-screen bg-black/50 backdrop-blur-sm">
<div class="p-8 bg-crypto-panel rounded-xl shadow-2xl border border-crypto-accent/20 text-center max-w-md w-full">
<h1 class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-400 to-pink-600 text-transparent bg-clip-text">
Crypto Chat
<div class="p-8 bg-crypto-panel rounded-2xl shadow-2xl border border-violet-500/20 text-center max-w-md w-full backdrop-blur-xl">
<!-- Logo -->
<div class="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-violet-600 via-fuchsia-600 to-pink-600 flex items-center justify-center shadow-xl shadow-violet-600/30">
<span class="text-3xl font-black text-white">P</span>
</div>
<h1 class="text-4xl font-black mb-2 bg-gradient-to-r from-violet-400 via-fuchsia-400 to-pink-400 text-transparent bg-clip-text">
Plexus
</h1>
<p class="text-crypto-muted mb-8">
Connect your wallet to join the conversation.
<p class="text-crypto-muted mb-8 text-sm">
Web3 Social Chat Connect wallet to join
</p>
<button
:disabled="isConnecting"
class="w-full py-3 px-6 bg-crypto-accent hover:bg-violet-600 text-white rounded-lg font-semibold transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
class="w-full py-4 px-6 bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white rounded-xl font-bold transition-all transform hover:scale-[1.02] hover:shadow-xl hover:shadow-violet-600/30 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 flex items-center justify-center gap-3"
@click="connectWallet"
>
<span v-if="isConnecting">Connecting...</span>
<span v-else>Connect Phantom Wallet</span>
<span v-if="isConnecting" class="flex items-center gap-2">
<div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Connecting...
</span>
<span v-else class="flex items-center gap-2">
<svg class="w-5 h-5" viewBox="0 0 128 128" fill="none">
<circle cx="64" cy="64" r="64" fill="url(#phantom-gradient)"/>
<path d="M110.5 64C110.5 90.5 89.5 112 64 112C38.5 112 17.5 90.5 17.5 64C17.5 37.5 38.5 16 64 16" stroke="white" stroke-width="8" stroke-linecap="round"/>
<defs>
<linearGradient id="phantom-gradient" x1="0" y1="0" x2="128" y2="128">
<stop offset="0%" stop-color="#AB9FF2"/>
<stop offset="100%" stop-color="#534BB1"/>
</linearGradient>
</defs>
</svg>
Connect Phantom Wallet
</span>
</button>
<p
v-if="error"
class="mt-4 text-red-400 text-sm"
class="mt-4 text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg py-2 px-3"
>
{{ error }}
</p>
<p class="mt-6 text-xs text-gray-600">
Don't have a wallet?
<a href="https://phantom.app/" target="_blank" class="text-violet-400 hover:text-violet-300 underline">Get Phantom</a>
</p>
</div>
</div>
</template>

View File

@@ -3,8 +3,11 @@ import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,64 @@
import { createRouter, createWebHistory } from 'vue-router';
import ChatLayout from '../components/ChatLayout.vue';
import WalletConnect from '../components/WalletConnect.vue';
import { useChatStore } from '../stores/chat';
const routes = [
{
path: '/',
redirect: '/chat/nebula'
},
{
path: '/chat/:channel?',
name: 'Chat',
component: ChatLayout,
meta: { requiresAuth: true },
props: true
},
{
path: '/profile/:address?',
name: 'Profile',
component: ChatLayout,
meta: { requiresAuth: true },
props: route => ({ viewProfile: true, profileAddress: route.params.address })
},
{
path: '/docs',
name: 'Docs',
component: ChatLayout,
meta: { requiresAuth: true },
props: { viewDocs: true }
},
{
path: '/changelog',
name: 'Changelog',
component: ChatLayout,
meta: { requiresAuth: true },
props: { viewChangelog: true }
},
{
path: '/login',
name: 'Login',
component: WalletConnect
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to, from, next) => {
const chatStore = useChatStore();
const isAuthenticated = chatStore.checkAuth();
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login');
} else if (to.path === '/login' && isAuthenticated) {
next('/chat/nebula');
} else {
next();
}
});
export default router;

View File

@@ -14,14 +14,18 @@ vi.mock('socket.io-client', () => ({
io: () => mockSocket
}));
// Mock js-cookie
vi.mock('js-cookie', () => ({
default: {
set: vi.fn(),
get: vi.fn(),
remove: vi.fn()
}
}));
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn(key => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = value.toString(); }),
removeItem: vi.fn(key => { delete store[key]; }),
clear: vi.fn(() => { store = {}; })
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
describe('Chat Store Web3 Economy', () => {
beforeEach(() => {
@@ -31,6 +35,7 @@ describe('Chat Store Web3 Economy', () => {
// Reset socket mocks
mockSocket.on.mockReset();
mockSocket.emit.mockReset();
localStorageMock.clear();
// Mock fetch
global.fetch = vi.fn(() => Promise.resolve({
@@ -94,4 +99,17 @@ describe('Chat Store Web3 Economy', () => {
expect(store.balance).toBe(50);
});
it('should auto-login if localStorage exists', () => {
const store = useChatStore();
const authData = { wallet: 'wallet123', name: 'user123', sig: 'sig123' };
// Mock localStorage
localStorage.setItem('plexus_auth', JSON.stringify(authData));
const isAuthenticated = store.checkAuth();
expect(isAuthenticated).toBe(true);
expect(store.walletAddress).toBe('wallet123');
expect(store.username).toBe('user123');
});
});

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import { io } from 'socket.io-client';
import { ref, computed } from 'vue';
import Cookies from 'js-cookie';
import { useRouter } from 'vue-router';
export const useChatStore = defineStore('chat', () => {
const socket = ref(null);
@@ -9,7 +9,7 @@ export const useChatStore = defineStore('chat', () => {
const walletAddress = ref(null);
const username = ref(null);
const signature = ref(null);
const balance = ref(100); // Mock initial balance
const balance = ref(parseInt(localStorage.getItem('plexus_balance')) || 100); // Init from storage or default 100
const currentChannel = ref('nebula');
const messages = ref({}); // { channelId: [messages] }
@@ -19,7 +19,9 @@ export const useChatStore = defineStore('chat', () => {
// Profile state
const profileUser = ref(null);
const profilePosts = ref([]);
const profileReposts = ref([]);
const isProfileLoading = ref(false);
const profilePicture = ref(localStorage.getItem('plexus_nft_pic') || null);
const onlineUsers = computed(() => users.value.filter(u => u.online));
const offlineUsers = computed(() => users.value.filter(u => !u.online));
@@ -31,7 +33,7 @@ export const useChatStore = defineStore('chat', () => {
username.value = name;
if (sig) {
signature.value = sig;
Cookies.set('plexus_auth', JSON.stringify({ wallet, name, sig }), { expires: 7 });
saveSession(wallet, name, sig);
}
// Connect to same origin (proxied by Vite in dev, Nginx in prod)
@@ -52,17 +54,22 @@ export const useChatStore = defineStore('chat', () => {
messages.value[message.channelId] = [];
}
// Check if this message matches a local pending message (by content and wallet)
// In a real app, we'd use the txId to match
// Check if this message matches a local pending message (by txId)
const pendingIdx = messages.value[message.channelId].findIndex(
m => m.status === 'pending' && m.content === message.content && m.walletAddress === message.walletAddress
m => m.status === 'pending' && m.txId === message.txId
);
if (pendingIdx !== -1) {
// Update the pending message with server data and mark as validated
messages.value[message.channelId][pendingIdx] = { ...message, status: 'validated' };
} else {
messages.value[message.channelId] = [...messages.value[message.channelId], { ...message, status: 'validated' }];
// Check if we already have this message (by id or txId) to prevent duplicates
const exists = messages.value[message.channelId].some(
m => (m.id && m.id === message.id) || (m.txId && m.txId === message.txId)
);
if (!exists) {
messages.value[message.channelId] = [...messages.value[message.channelId], { ...message, status: 'validated' }];
}
}
});
@@ -71,43 +78,98 @@ export const useChatStore = defineStore('chat', () => {
});
socket.value.on('updateReactions', ({ messageId, reactions }) => {
console.log('Received updateReactions for message:', messageId, 'reactions:', reactions);
for (const channelId in messages.value) {
const msg = messages.value[channelId].find(m => m.id === messageId);
const msg = messages.value[channelId].find(m => String(m.id) === String(messageId));
if (msg) {
msg.reactions = reactions;
// Trigger reactivity by replacing the array
messages.value[channelId] = [...messages.value[channelId]];
break;
}
}
});
socket.value.on('profileData', (data) => {
profileUser.value = data;
profilePosts.value = data.posts;
console.log('Profile data received:', data);
if (!data) return;
profileUser.value = {
wallet_address: data.wallet_address || '',
username: data.username || 'Anonymous',
bio: data.bio || '',
banner_color: data.banner_color || '#6366f1',
last_seen: data.last_seen || new Date().toISOString()
};
profilePosts.value = Array.isArray(data.posts) ? data.posts : [];
profileReposts.value = Array.isArray(data.reposts) ? data.reposts : [];
isProfileLoading.value = false;
});
socket.value.on('profileUpdated', (data) => {
if (profileUser.value && profileUser.value.wallet_address === walletAddress.value) {
console.log('Profile updated event:', data);
if (!data) return;
if (profileUser.value && profileUser.value.wallet_address === data.wallet_address) {
profileUser.value = { ...profileUser.value, ...data };
}
// Also update main user state if it's the current user
if (data.username && data.wallet_address === walletAddress.value) {
username.value = data.username;
localStorage.setItem('plexus_username', data.username);
}
});
socket.value.on('postCreated', (post) => {
profilePosts.value = [post, ...profilePosts.value];
});
socket.value.on('commentCreated', (comment) => {
const post = profilePosts.value.find(p => p.id === comment.post_id);
if (post) {
if (!post.comments) post.comments = [];
post.comments.push(comment);
}
});
socket.value.on('commentsLoaded', ({ postId, comments }) => {
const post = profilePosts.value.find(p => p.id === postId);
if (post) {
post.comments = comments;
// Update count just in case
post.comment_count = comments.length;
}
});
socket.value.on('repostToggled', ({ postId, walletAddress: repostWallet, action }) => {
const post = profilePosts.value.find(p => p.id === postId);
if (post) {
if (!post.reposted_by) post.reposted_by = [];
if (!post.repost_count) post.repost_count = 0;
if (action === 'added') {
if (!post.reposted_by.includes(repostWallet)) {
post.reposted_by.push(repostWallet);
post.repost_count++;
}
} else if (action === 'removed') {
const idx = post.reposted_by.indexOf(repostWallet);
if (idx > -1) {
post.reposted_by.splice(idx, 1);
post.repost_count = Math.max(0, post.repost_count - 1);
}
}
}
});
socket.value.on('usernameUpdated', ({ username: newName }) => {
username.value = newName;
const savedAuth = Cookies.get('plexus_auth');
if (savedAuth) {
const authData = JSON.parse(savedAuth);
authData.name = newName;
Cookies.set('plexus_auth', JSON.stringify(authData), { expires: 7 });
}
saveSession(walletAddress.value, newName, signature.value);
});
socket.value.on('balanceUpdated', ({ balance: newBalance }) => {
balance.value = newBalance;
localStorage.setItem('plexus_balance', newBalance.toString());
});
socket.value.on('error', (err) => {
@@ -133,6 +195,9 @@ export const useChatStore = defineStore('chat', () => {
balance.value -= 1;
const tempId = 'temp-' + Date.now();
// Generate txId immediately for tracking and matching
const mockTxId = 'TX' + Math.random().toString(36).substring(2, 15).toUpperCase();
const pendingMsg = {
tempId,
channelId: currentChannel.value,
@@ -141,19 +206,10 @@ export const useChatStore = defineStore('chat', () => {
content,
timestamp: new Date().toISOString(),
status: 'pending',
txId: mockTxId,
reactions: []
};
// Add to local state immediately
if (!messages.value[currentChannel.value]) {
messages.value[currentChannel.value] = [];
}
messages.value[currentChannel.value].push(pendingMsg);
// Simulate a blockchain transaction delay
console.log('Simulating 1 $PLEXUS transaction for message...');
const mockTxId = 'TX' + Math.random().toString(36).substring(2, 15).toUpperCase();
setTimeout(() => {
// Randomly fail 5% of the time for demonstration
const failed = Math.random() < 0.05;
@@ -185,6 +241,7 @@ export const useChatStore = defineStore('chat', () => {
console.log('Simulating 1 $PLEXUS transaction for reaction...');
socket.value.emit('toggleReaction', {
channelId: currentChannel.value,
messageId,
walletAddress: walletAddress.value,
emoji
@@ -228,6 +285,46 @@ export const useChatStore = defineStore('chat', () => {
});
}
function createComment(postId, content) {
if (!socket.value || !content.trim()) return;
socket.value.emit('createComment', {
postId,
walletAddress: walletAddress.value,
content
});
}
function fetchComments(postId) {
if (!socket.value) return;
socket.value.emit('getComments', postId);
}
function repost(postId) {
if (!socket.value || !walletAddress.value) return;
socket.value.emit('repost', {
postId,
walletAddress: walletAddress.value
});
}
function startDM(targetWallet) {
if (!walletAddress.value || !targetWallet) return;
// Create deterministic channel ID: dm:min(addr1,addr2):max(addr1,addr2)
const [addr1, addr2] = [walletAddress.value, targetWallet].sort();
const dmChannelId = `dm:${addr1}:${addr2}`;
// Add to channels list if not exists
if (!channels.value.find(c => c.id === dmChannelId)) {
channels.value.push({
id: dmChannelId,
name: `DM: ${targetWallet.slice(0, 4)}...`
});
}
setChannel(dmChannelId);
return dmChannelId;
}
function setChannel(channelId) {
currentChannel.value = channelId;
if (!messages.value[channelId]) {
@@ -238,7 +335,10 @@ export const useChatStore = defineStore('chat', () => {
async function fetchChannels() {
try {
const res = await fetch('/api/channels');
channels.value = await res.json();
const data = await res.json();
// Keep existing DM channels
const dms = channels.value.filter(c => c.id.startsWith('dm:'));
channels.value = [...data, ...dms];
} catch (e) {
console.error('Failed to fetch channels', e);
}
@@ -248,12 +348,66 @@ export const useChatStore = defineStore('chat', () => {
try {
const res = await fetch(`/api/messages/${channelId}`);
const data = await res.json();
messages.value[channelId] = data;
// Map snake_case to camelCase and set status
messages.value[channelId] = data.map(m => ({
...m,
txId: m.tx_id,
status: 'validated' // Messages from DB are confirmed
}));
} catch (e) {
console.error('Failed to fetch messages', e);
}
}
function saveSession(wallet, name, sig) {
try {
localStorage.setItem('plexus_auth', JSON.stringify({ wallet, name, sig }));
} catch (e) {
console.error('Failed to save session', e);
}
}
function checkAuth() {
const savedAuth = localStorage.getItem('plexus_auth');
if (savedAuth) {
try {
const { wallet, name, sig } = JSON.parse(savedAuth);
if (wallet && name) {
if (!isConnected.value) {
connect(wallet, name, sig);
}
return true;
}
} catch (e) {
console.error('Failed to parse auth', e);
}
}
return false;
}
function logout() {
localStorage.removeItem('plexus_auth');
localStorage.removeItem('plexus_balance');
walletAddress.value = null;
username.value = null;
signature.value = null;
balance.value = 100;
isConnected.value = false;
if (socket.value) {
socket.value.disconnect();
}
// Router redirect will be handled by component or global guard
}
function setProfilePicture(url) {
profilePicture.value = url;
if (url) {
localStorage.setItem('plexus_nft_pic', url);
} else {
localStorage.removeItem('plexus_nft_pic');
}
}
return {
socket,
isConnected,
@@ -268,7 +422,9 @@ export const useChatStore = defineStore('chat', () => {
currentMessages,
profileUser,
profilePosts,
profileReposts,
isProfileLoading,
profilePicture,
connect,
sendMessage,
toggleReaction,
@@ -276,8 +432,14 @@ export const useChatStore = defineStore('chat', () => {
getProfile,
updateProfile,
createPost,
createPost,
createComment,
fetchComments,
repost,
startDM,
setChannel,
balance
setProfilePicture,
balance,
checkAuth,
logout
};
});