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:
15
README.md
15
README.md
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
24
client/package-lock.json
generated
24
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
76
client/src/components/ChangelogView.vue
Normal file
76
client/src/components/ChangelogView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
72
client/src/components/DocsView.vue
Normal file
72
client/src/components/DocsView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
64
client/src/router/index.js
Normal file
64
client/src/router/index.js
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
```
|
||||
141
server/db.js
141
server/db.js
@@ -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 };
|
||||
|
||||
443
server/index.js
443
server/index.js
@@ -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}`);
|
||||
});
|
||||
|
||||
46
server/test_profile_fix.js
Normal file
46
server/test_profile_fix.js
Normal 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);
|
||||
63
server/tests/social.test.js
Normal file
63
server/tests/social.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user