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

@@ -24,12 +24,13 @@ Plexus is a **Club 2.0** platform—a hybrid between a live chat and a social ne
Explore our detailed documentation in the `docs/` directory:
- [🏗 Architecture](file:///home/sinan/Documents/repositories/local/plexus/docs/architecture.md) - High-level system design.
- [📂 Structure](file:///home/sinan/Documents/repositories/local/plexus/docs/structure.md) - Directory and file organization.
- [⚙️ Functions & API](file:///home/sinan/Documents/repositories/local/plexus/docs/functions.md) - Socket events and backend logic.
- [📊 Data Model](file:///home/sinan/Documents/repositories/local/plexus/docs/data-model.md) - Database schema and migrations.
- [📈 Scalability](file:///home/sinan/Documents/repositories/local/plexus/docs/scalability.md) - Future roadmap and scaling strategies.
- [📝 Task Tracker](file:///home/sinan/Documents/repositories/local/plexus/docs/tasks.md) - How to use the internal task management tool.
- [O Vision](./docs/vision.md) - Platform vision.
- [🏗 Architecture](./docs/architecture.md) - High-level system design.
- [📂 Structure](./docs/structure.md) - Directory and file organization.
- [⚙️ Functions & API](./docs/functions.md) - Socket events and backend logic.
- [📊 Data Model](./docs/data-model.md) - Database schema and migrations.
- [📈 Scalability](./docs/scalability.md) - Future roadmap and scaling strategies.
- [📝 Task Tracker](./docs/tasks.md) - How to use the internal task management tool.
## 🚦 Quick Start
@@ -55,7 +56,7 @@ cd client && npm run dev
## 🤝 Contributing
We follow a strict development workflow. Please read [CONTRIBUTING.md](file:///home/sinan/Documents/repositories/local/plexus/CONTRIBUTING.md) before starting.
We follow a strict development workflow. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) before starting.
1. **Pick a Task**: Use `make task-list` to find something to work on.
2. **Code**: Implement your changes.

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
};
});

View File

@@ -1,21 +1,24 @@
version: '3.8'
services:
server:
api:
build: ./server
ports:
- "3000:3000"
volumes:
- ./data:/app/data
- ./server:/app
environment:
- PORT=3000
env_file:
- .env
client:
build: ./client
ports:
- "8080:80"
depends_on:
- server
- api
volumes:
- ./client:/app
dev-shell:
build:

View File

@@ -24,6 +24,7 @@ Des compétitions entre salons, ... .
## Un réeau social
Mais pas un réseau social moderne. Nous voulons retrouver les vibes de l'internet des années 2000. Former des groupes, des amitiés, ... .
Les membres ont une page personnel qu'ils peuvent mettre à jour / customiser, faire des posts, ... envoyer des messages privés, ... .
@@ -31,11 +32,3 @@ Les membres ont une page personnel qu'ils peuvent mettre à jour / customiser, f
On peut imaginer un boutton IA qui peut lire les messages pour les résumer à la demande de l'utilisateur, ...
```prompt
lis docs/vision.md et le reste des readme
vision.md est le dernier document, il faudrait tendre vers ca. Je te laisse définir un plan d'action.
Connaissant les limites du vibe coding, essaye de faire pour le mieux.
```

View File

@@ -7,82 +7,89 @@ const db = new duckdb.Database(dbPath);
const con = db.connect();
// Initialize Schema
con.exec(`
CREATE TABLE IF NOT EXISTS users (
wallet_address VARCHAR PRIMARY KEY,
username VARCHAR UNIQUE,
bio VARCHAR DEFAULT '',
banner_color VARCHAR DEFAULT '#6366f1',
balance INTEGER DEFAULT 100,
last_seen TIMESTAMP
);
function initDb() {
return new Promise((resolve, reject) => {
try {
con.exec(`
CREATE TABLE IF NOT EXISTS users (
wallet_address VARCHAR PRIMARY KEY,
username VARCHAR UNIQUE,
bio VARCHAR DEFAULT '',
banner_color VARCHAR DEFAULT '#6366f1',
balance INTEGER DEFAULT 100,
last_seen TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
channel_id VARCHAR,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
tx_id VARCHAR,
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
channel_id VARCHAR,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
tx_id VARCHAR,
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE TABLE IF NOT EXISTS reactions (
message_id INTEGER,
wallet_address VARCHAR,
emoji VARCHAR,
PRIMARY KEY (message_id, wallet_address, emoji),
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE TABLE IF NOT EXISTS reactions (
message_id INTEGER,
wallet_address VARCHAR,
emoji VARCHAR,
PRIMARY KEY (message_id, wallet_address, emoji),
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1;
CREATE SEQUENCE IF NOT EXISTS seq_post_id START 1;
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
-- Migration: Add tx_id to messages if it doesn't exist (for existing DBs)
PRAGMA table_info('messages');
`, (err) => {
if (err) return console.error('Schema error:', err);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY,
post_id INTEGER,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id),
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
// Check if tx_id exists, if not add it
con.all("PRAGMA table_info('messages')", (err, rows) => {
if (err) return;
const hasTxId = rows.some(r => r.name === 'tx_id');
if (!hasTxId) {
con.run("ALTER TABLE messages ADD COLUMN tx_id VARCHAR", (err) => {
if (err) console.error("Error adding tx_id column:", err);
CREATE TABLE IF NOT EXISTS reposts (
id INTEGER PRIMARY KEY,
post_id INTEGER,
wallet_address VARCHAR,
timestamp TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id),
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1;
CREATE SEQUENCE IF NOT EXISTS seq_post_id START 1;
CREATE SEQUENCE IF NOT EXISTS seq_comment_id START 1;
CREATE SEQUENCE IF NOT EXISTS seq_repost_id START 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_reposts_unique ON reposts(post_id, wallet_address);
`, (err) => {
if (err) {
console.error('Schema initialization error:', err);
return resolve(); // Resolve anyway so server starts
}
console.log('Database schema created/verified');
resolve();
});
} catch (e) {
console.error('Fatal database initialization error:', e);
resolve();
}
});
}
// Migration: Add bio and banner_color to users
con.all("PRAGMA table_info('users')", (err, rows) => {
if (err) return;
const hasBio = rows.some(r => r.name === 'bio');
if (!hasBio) {
con.run("ALTER TABLE users ADD COLUMN bio VARCHAR DEFAULT ''", (err) => {
if (err) console.error("Error adding bio column:", err);
});
con.run("ALTER TABLE users ADD COLUMN banner_color VARCHAR DEFAULT '#6366f1'", (err) => {
if (err) console.error("Error adding banner_color column:", err);
});
}
const hasBalance = rows.some(r => r.name === 'balance');
if (!hasBalance) {
con.run("ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 100", (err) => {
if (err) console.error("Error adding balance column:", err);
});
}
});
console.log('Database initialized and cleared');
initDb().then(() => {
console.log('Database initialized successfully');
}).catch(err => {
console.error('Failed to initialize database (continuing anyway):', err);
});
module.exports = { db, con };

View File

@@ -50,29 +50,21 @@ io.on('connection', (socket) => {
if (err) return console.error("Execute error:", err);
if (rows.length > 0) {
// User exists, update last seen
const existingUsername = rows[0].username;
con.prepare(`UPDATE users SET last_seen = ? WHERE wallet_address = ?`, (err, uStmt) => {
// User exists, update last seen and ensure balance
const existingUser = rows[0];
const existingUsername = existingUser.username;
const existingBalance = existingUser.balance ?? 100;
con.prepare(`UPDATE users SET last_seen = ?, balance = ? WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return console.error("Prepare error:", err);
uStmt.run(now, walletAddress, (err) => {
// If balance is NULL or 0, give them 100
const finalBalance = (existingBalance < 30) ? 100 : existingBalance;
uStmt.run(now, finalBalance, walletAddress, (err) => {
uStmt.finalize();
if (err) console.error("Update error:", err);
socket.emit('usernameUpdated', { username: existingUsername });
// Send balance
con.prepare(`SELECT balance FROM users WHERE wallet_address = ?`, (err, bStmt) => {
if (err) return console.error("Balance prepare error:", err);
bStmt.all(walletAddress, (err, bRows) => {
bStmt.finalize();
if (err) return console.error("Balance fetch error:", err);
if (bRows.length > 0) {
socket.emit('balanceUpdated', { balance: bRows[0].balance });
} else {
console.error("No user found for balance fetch");
}
});
});
socket.emit('balanceUpdated', { balance: finalBalance });
broadcastUserList();
});
});
@@ -89,13 +81,13 @@ io.on('connection', (socket) => {
finalUsername = `${username}_${walletAddress.slice(0, 4)}`;
}
con.prepare(`INSERT INTO users (wallet_address, username, last_seen) VALUES (?, ?, ?)`, (err, iStmt) => {
con.prepare(`INSERT INTO users (wallet_address, username, last_seen, balance) VALUES (?, ?, ?, ?)`, (err, iStmt) => {
if (err) return console.error("Prepare error:", err);
iStmt.run(walletAddress, finalUsername, now, (err) => {
iStmt.run(walletAddress, finalUsername, now, 100, (err) => {
iStmt.finalize();
if (err) console.error("Insert error:", err);
socket.emit('usernameUpdated', { username: finalUsername });
socket.emit('balanceUpdated', { balance: 100 }); // Default balance
socket.emit('balanceUpdated', { balance: 100 });
broadcastUserList();
});
});
@@ -109,59 +101,63 @@ io.on('connection', (socket) => {
socket.on('updateUsername', ({ walletAddress, newUsername, txId }) => {
console.log(`User ${walletAddress} requesting username change to ${newUsername} (TX: ${txId})`);
// Check if username is taken
con.prepare(`SELECT wallet_address FROM users WHERE username = ?`, (err, stmt) => {
// First check if user exists and has enough balance
con.prepare(`SELECT balance FROM users WHERE wallet_address = ?`, (err, bStmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.all(newUsername, (err, rows) => {
stmt.finalize();
bStmt.all(walletAddress, (err, rows) => {
bStmt.finalize();
if (err) return socket.emit('error', { message: 'Database error' });
if (rows.length === 0) return socket.emit('error', { message: 'User not found' });
if (rows[0].balance < 30) return socket.emit('error', { message: 'Insufficient $PLEXUS balance' });
if (rows.length > 0) {
return socket.emit('error', { message: 'Username already taken' });
}
// Check if username is taken
con.prepare(`SELECT wallet_address FROM users WHERE username = ?`, (err, stmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.all(newUsername, (err, uRows) => {
stmt.finalize();
if (err) return socket.emit('error', { message: 'Database error' });
// Update username
con.prepare(`UPDATE users SET username = ? WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return socket.emit('error', { message: 'Failed to update username' });
uStmt.run(newUsername, walletAddress, (err) => {
uStmt.finalize();
if (err) return socket.emit('error', { message: 'Failed to update username' });
if (uRows.length > 0) {
return socket.emit('error', { message: 'Username already taken' });
}
console.log(`Username updated for ${walletAddress} to ${newUsername}`);
socket.emit('usernameUpdated', { username: newUsername });
// Update username and deduct balance in one go if possible (or chain)
con.prepare(`UPDATE users SET username = ?, balance = balance - 30 WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return socket.emit('error', { message: 'Failed to update username' });
uStmt.run(newUsername, walletAddress, (err) => {
uStmt.finalize();
if (err) return socket.emit('error', { message: 'Failed to update username' });
// Deduct 30 PLEXUS
con.prepare(`UPDATE users SET balance = balance - 30 WHERE wallet_address = ?`, (err, bStmt) => {
if (!err) {
bStmt.run(walletAddress, () => {
bStmt.finalize();
// Fetch new balance
con.prepare(`SELECT balance FROM users WHERE wallet_address = ?`, (err, sStmt) => {
if (!err) {
sStmt.all(walletAddress, (err, rows) => {
sStmt.finalize();
if (!err && rows.length > 0) {
socket.emit('balanceUpdated', { balance: rows[0].balance });
}
});
}
});
console.log(`Username updated for ${walletAddress} to ${newUsername}`);
socket.emit('usernameUpdated', { username: newUsername });
// Fetch new balance to sync
con.prepare(`SELECT balance FROM users WHERE wallet_address = ?`, (err, sStmt) => {
if (!err) {
sStmt.all(walletAddress, (err, rRows) => {
sStmt.finalize();
if (!err && rRows.length > 0) {
socket.emit('balanceUpdated', { balance: rRows[0].balance });
}
});
}
});
}
broadcastUserList();
// Also broadcast a system message about the change
const systemMsg = {
id: Date.now(),
channelId: 'nebula',
walletAddress: 'system',
username: 'System',
content: `${walletAddress.slice(0, 4)}... changed their name to ${newUsername}`,
timestamp: new Date().toISOString(),
status: 'validated'
};
io.emit('newMessage', systemMsg);
});
});
broadcastUserList();
// Also broadcast a system message about the change
const systemMsg = {
id: Date.now(),
channelId: 'nebula',
walletAddress: 'system',
username: 'System',
content: `${walletAddress.slice(0, 4)}... changed their name to ${newUsername}`,
timestamp: new Date().toISOString()
};
io.emit('newMessage', systemMsg);
});
});
});
@@ -239,27 +235,150 @@ io.on('connection', (socket) => {
});
});
socket.on('getProfile', (walletAddress) => {
console.log(`Fetching profile for ${walletAddress}`);
con.prepare(`SELECT wallet_address, username, bio, banner_color, last_seen FROM users WHERE wallet_address = ?`, (err, stmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.all(walletAddress, (err, rows) => {
socket.on('getProfile', (targetAddress) => {
console.log(`[Profile] Fetching for: ${targetAddress}`);
con.prepare(`SELECT wallet_address, username, bio, banner_color, balance, last_seen FROM users WHERE wallet_address = ?`, (err, stmt) => {
if (err) {
console.error('[Profile] DB Prepare error:', err);
return socket.emit('error', { message: 'Database error' });
}
stmt.all(targetAddress, (err, rows) => {
stmt.finalize();
if (err || rows.length === 0) return socket.emit('error', { message: 'User not found' });
if (err) {
console.error('[Profile] DB Exec error:', err);
return socket.emit('error', { message: 'Database error' });
}
if (rows.length === 0) {
console.warn(`[Profile] User not found: ${targetAddress}`);
return socket.emit('error', { message: 'User not found' });
}
const user = rows[0];
// Fetch posts
con.prepare(`SELECT * FROM posts WHERE wallet_address = ? ORDER BY timestamp DESC LIMIT 50`, (err, pStmt) => {
if (err) return socket.emit('profileData', { ...user, posts: [] });
pStmt.all(walletAddress, (err, posts) => {
// Helper to handle BigInt serialization
const serializeBigInt = (data) => {
return JSON.parse(JSON.stringify(data, (key, value) =>
typeof value === 'bigint'
? value.toString()
: value
));
};
// Fetch posts with comment counts and repost counts
con.prepare(`
SELECT p.*,
(SELECT CAST(COUNT(*) AS INTEGER) FROM comments WHERE post_id = p.id) as comment_count,
(SELECT CAST(COUNT(*) AS INTEGER) FROM reposts WHERE post_id = p.id) as repost_count
FROM posts p
WHERE p.wallet_address = ?
ORDER BY p.timestamp DESC
LIMIT 50
`, (err, pStmt) => {
if (err) {
console.error('[Profile] Posts prepare error:', err);
return socket.emit('profileData', serializeBigInt({ ...user, posts: [], reposts: [] }));
}
pStmt.all(targetAddress, (err, posts) => {
pStmt.finalize();
socket.emit('profileData', { ...user, posts: posts || [] });
if (err) console.error('[Profile] Posts exec error:', err);
posts = posts || [];
// Fetch who reposted each post
if (posts.length > 0) {
const postIds = posts.map(p => p.id);
con.prepare(`SELECT post_id, wallet_address FROM reposts WHERE post_id IN (${postIds.join(',')})`, (err, rStmt) => {
if (err) {
console.error('[Profile] Reposts prepare error:', err);
posts = posts.map(p => ({ ...p, reposted_by: [] }));
emitProfileWithReposts();
return;
}
rStmt.all((err, repostRows) => {
rStmt.finalize();
if (!err && repostRows) {
posts = posts.map(p => ({
...p,
reposted_by: repostRows.filter(r => r.post_id === p.id).map(r => r.wallet_address)
}));
} else {
posts = posts.map(p => ({ ...p, reposted_by: [] }));
}
emitProfileWithReposts();
});
});
} else {
emitProfileWithReposts();
}
// Fetch posts this user reposted (from other users)
function emitProfileWithReposts() {
con.prepare(`
SELECT p.*, u.username as original_username, r.timestamp as repost_timestamp
FROM reposts r
JOIN posts p ON r.post_id = p.id
JOIN users u ON p.wallet_address = u.wallet_address
WHERE r.wallet_address = ?
ORDER BY r.timestamp DESC
LIMIT 20
`, (err, rpStmt) => {
if (err) {
console.error('[Profile] User reposts error:', err);
return socket.emit('profileData', serializeBigInt({ ...user, posts, reposts: [] }));
}
rpStmt.all(targetAddress, (err, userReposts) => {
rpStmt.finalize();
socket.emit('profileData', serializeBigInt({
...user,
posts,
reposts: userReposts || []
}));
});
});
}
});
});
});
});
});
socket.on('repost', ({ postId, walletAddress }) => {
console.log(`User ${walletAddress} toggling repost for post ${postId}`);
// Check if user already reposted this post
con.prepare(`SELECT id FROM reposts WHERE post_id = ? AND wallet_address = ?`, (err, checkStmt) => {
if (err) return console.error("Prepare error:", err);
checkStmt.all(postId, walletAddress, (err, rows) => {
checkStmt.finalize();
if (err) return console.error("Check error:", err);
if (rows.length > 0) {
// Already reposted, so toggle OFF (delete)
const repostId = rows[0].id;
con.prepare(`DELETE FROM reposts WHERE id = ?`, (err, delStmt) => {
if (err) return console.error("Delete prepare error:", err);
delStmt.run(repostId, (err) => {
delStmt.finalize();
if (err) return console.error("Delete error:", err);
io.emit('repostToggled', { postId, walletAddress, action: 'removed' });
});
});
} else {
// Not reposted yet, so toggle ON (insert)
const timestamp = new Date().toISOString();
con.prepare(`INSERT INTO reposts (id, post_id, wallet_address, timestamp) VALUES (nextval('seq_repost_id'), ?, ?, ?) RETURNING id`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(postId, walletAddress, timestamp, (err, insertRows) => {
stmt.finalize();
if (err) return console.error("Insert error:", err);
io.emit('repostToggled', { postId, walletAddress, repostId: insertRows[0].id, action: 'added' });
});
});
}
});
});
});
socket.on('updateProfile', ({ walletAddress, bio, bannerColor }) => {
console.log(`Updating profile for ${walletAddress}`);
con.prepare(`UPDATE users SET bio = ?, banner_color = ? WHERE wallet_address = ?`, (err, stmt) => {
@@ -274,22 +393,73 @@ io.on('connection', (socket) => {
});
socket.on('createPost', ({ walletAddress, content }) => {
if (!content || content.trim() === '') return;
console.log(`New post from ${walletAddress}: ${content}`);
console.log(`Creating post for ${walletAddress}`);
const timestamp = new Date().toISOString();
con.prepare(`INSERT INTO posts (id, wallet_address, content, timestamp) VALUES (nextval('seq_post_id'), ?, ?, ?)`, (err, stmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.run(walletAddress, content, timestamp, (err) => {
con.prepare(`INSERT INTO posts (id, wallet_address, content, timestamp) VALUES (nextval('seq_post_id'), ?, ?, ?) RETURNING id`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(walletAddress, content, timestamp, (err, rows) => {
stmt.finalize();
if (err) return socket.emit('error', { message: 'Failed to create post' });
if (err) return console.error("Insert error:", err);
// Fetch all posts to broadcast update or just emit the new one
// For simplicity, we'll just tell the user it was created
socket.emit('postCreated', { content, timestamp });
const post = {
id: rows[0].id,
wallet_address: walletAddress,
content,
timestamp,
comments: []
};
// If we want a live feed, we could broadcast to a "profile room"
// For now, the user can just refresh or we emit to them
// Broadcast to all (or just profile viewers? for now all)
io.emit('postCreated', post);
});
});
});
socket.on('createComment', ({ postId, walletAddress, content }) => {
console.log(`Creating comment on post ${postId} by ${walletAddress}`);
const timestamp = new Date().toISOString();
con.prepare(`INSERT INTO comments (id, post_id, wallet_address, content, timestamp) VALUES (nextval('seq_comment_id'), ?, ?, ?, ?) RETURNING id`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(postId, walletAddress, content, timestamp, (err, rows) => {
stmt.finalize();
if (err) return console.error("Insert error:", err);
// Fetch username
con.prepare(`SELECT username FROM users WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return;
uStmt.all(walletAddress, (err, uRows) => {
uStmt.finalize();
const username = uRows.length > 0 ? uRows[0].username : walletAddress.slice(0, 4);
const comment = {
id: rows[0].id,
post_id: postId,
wallet_address: walletAddress,
username,
content,
timestamp
};
io.emit('commentCreated', comment);
});
});
});
});
});
socket.on('getComments', (postId) => {
con.prepare(`
SELECT c.*, u.username
FROM comments c
LEFT JOIN users u ON c.wallet_address = u.wallet_address
WHERE c.post_id = ?
ORDER BY c.timestamp ASC
`, (err, stmt) => {
if (err) return;
stmt.all(postId, (err, rows) => {
stmt.finalize();
if (!err) {
socket.emit('commentsLoaded', { postId, comments: rows });
}
});
});
});
@@ -410,6 +580,99 @@ app.get('/api/messages/:channelId', (req, res) => {
});
const PORT = process.env.PORT || 3000;
// AI Summary Endpoint
app.post('/api/summary', async (req, res) => {
const { channelId } = req.body;
console.log(`[AI] Summary request for channel: ${channelId}`);
if (!process.env.OPENROUTER_API_KEY) {
console.error('[AI] Missing OPENROUTER_API_KEY environment variable');
return res.status(500).json({ error: 'AI service not configured (API key missing)' });
}
try {
con.prepare(`
SELECT m.content, u.username, m.timestamp
FROM messages m
JOIN users u ON m.wallet_address = u.wallet_address
WHERE m.channel_id = ?
ORDER BY m.timestamp DESC
LIMIT 50
`, (err, stmt) => {
if (err) {
console.error('[AI] DB Prepare error:', err);
return res.status(500).json({ error: 'Database error' });
}
stmt.all(channelId, async (err, rows) => {
stmt.finalize();
if (err) {
console.error('[AI] DB Execution error:', err);
return res.status(500).json({ error: 'Database error' });
}
if (!rows || rows.length === 0) {
console.log(`[AI] No messages found for channel ${channelId}`);
return res.json({ summary: "This channel is a quiet void... for now. Send some messages to generate a summary!" });
}
const conversation = rows.reverse().map(r => `${r.username}: ${r.content}`).join('\n');
console.log(`[AI] Summarizing ${rows.length} messages...`);
try {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://plexus.social",
"X-Title": "Plexus Social"
},
body: JSON.stringify({
"model": "google/learnlm-1.5-pro-experimental:free",
"messages": [
{
"role": "system",
"content": `You are Plexus AI, a high-signal crypto analyst.
Summarize the conversation for #${channelId} with extreme precision.
Structure your output in Markdown:
# 📊 EXECUTIVE SUMMARY
# 💎 KEY TOPICS & ALPHA
# 🎭 SENTIMENT ANALYSIS
# 📜 NOTABLE QUOTES
Use emojis and bold text for impact. Keep it high-signal.`
},
{
"role": "user",
"content": `Analyze and summarize this conversation:\n\n${conversation}`
}
]
})
});
const data = await response.json();
if (data.choices && data.choices[0]) {
console.log('[AI] Summary generated successfully');
res.json({ summary: data.choices[0].message.content });
} else {
console.error('[AI] OpenRouter error response:', JSON.stringify(data));
res.status(500).json({ error: 'AI Error: ' + (data.error?.message || 'Unknown provider error') });
}
} catch (apiErr) {
console.error('[AI] Fetch exception:', apiErr);
res.status(500).json({ error: 'Failed to reach the AI collective.' });
}
});
});
} catch (e) {
console.error('[AI] Critical error:', e);
res.status(500).json({ error: 'Internal Server Error' });
}
});
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@@ -0,0 +1,46 @@
const { io } = require("socket.io-client");
const socket = io("http://localhost:3000");
const walletAddress = "8vRn2vQb3RsDw2yNtyQESodn7Tr6THzgAgn1ai7srdvM"; // Use the one from logs
const username = "TestUser";
console.log("Connecting to server...");
socket.on("connect", () => {
console.log("Connected:", socket.id);
socket.emit("join", { walletAddress, username });
});
socket.on("balanceUpdated", (data) => {
console.log("Joined successfully, balance:", data.balance);
console.log("Requesting profile...");
socket.emit("getProfile", walletAddress);
});
socket.on("profileData", (data) => {
console.log("SUCCESS: Profile data received!");
console.log("Username:", data.username);
console.log("Comment Count Type:", typeof data.posts[0]?.comment_count); // Should be number/string, not BigInt (object in JS if not serialized)
// Check if serialization worked (BigInts become strings usually)
// But data over JSON is already parsed.
console.log("Data sample:", JSON.stringify(data, null, 2));
process.exit(0);
});
socket.on("error", (err) => {
console.error("Error received:", err);
process.exit(1);
});
socket.on("disconnect", () => {
console.log("Disconnected from server");
});
// Timeout
setTimeout(() => {
console.error("Timeout waiting for profile data");
process.exit(1);
}, 5000);

View File

@@ -0,0 +1,63 @@
const io = require('socket.io-client');
const assert = require('chai').assert;
describe('Social Features (RT & Profile)', function () {
this.timeout(5000);
let client;
const testWallet = 'social_test_wallet_' + Date.now();
before((done) => {
client = io('http://localhost:3000');
client.on('connect', () => {
client.emit('join', { walletAddress: testWallet, username: 'SocialUser' });
done();
});
});
after(() => {
client.disconnect();
});
it('should update profile bio and banner', (done) => {
const updateData = {
walletAddress: testWallet,
bio: 'New bio content',
bannerColor: '#ff0000'
};
client.emit('updateProfile', updateData);
client.on('profileUpdated', (data) => {
assert.equal(data.bio, 'New bio content');
assert.equal(data.bannerColor, '#ff0000');
done();
});
});
it('should create a post and then repost it', (done) => {
client.emit('createPost', { walletAddress: testWallet, content: 'Social Post' });
client.once('postCreated', (post) => {
assert.equal(post.content, 'Social Post');
const postId = post.id;
client.emit('repost', { postId, walletAddress: testWallet });
client.on('postReposted', (repostData) => {
assert.equal(repostData.postId, postId);
assert.equal(repostData.walletAddress, testWallet);
done();
});
});
});
it('should fetch profile with correct post counts', (done) => {
client.emit('getProfile', testWallet);
client.on('profileData', (data) => {
assert.equal(data.wallet_address, testWallet);
assert.isArray(data.posts);
// The post we just created should have a repost count of 1
const post = data.posts.find(p => p.content === 'Social Post');
assert.exists(post);
assert.equal(post.repost_count, 1);
done();
});
});
});