Compare commits
3 Commits
712f62f7ae
...
62280265b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 62280265b4 | |||
| 959b453d69 | |||
| 64060f6a01 |
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
npx lint-staged
|
|
||||||
24
README.md
24
README.md
@@ -1,16 +1,17 @@
|
|||||||
# 🌌 Plexus
|
# 🌌 Plexus
|
||||||
|
|
||||||
Plexus is a premium, decentralized-inspired chat application built with **Vue 3**, **Node.js**, **Socket.io**, and **DuckDB**. It features a sleek Discord-style interface, real-time messaging, and social profiles with customizable "walls".
|
Plexus is a **Club 2.0** platform—a hybrid between a live chat and a social network, powered by a simulated **Web3 economy**. It features a "cozy" atmosphere with background music, decentralized-inspired identity, and salon-based communities.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 🚀 Key Features
|
## 🚀 Key Features
|
||||||
|
|
||||||
- **💎 Premium UI**: Discord-inspired dark theme with glassmorphism and smooth animations.
|
- **💎 Club 2.0 Experience**: A cozy digital salon with background music and shared vibes.
|
||||||
|
- **🪙 Web3 Economy**: Simulated $PLEXUS token. **1 Message = 1 $PLEXUS**.
|
||||||
|
- **🆔 Identity**: Phantom Wallet login and NFT profile pictures.
|
||||||
- **📱 Mobile Ready**: Fully responsive layout with adaptive components.
|
- **📱 Mobile Ready**: Fully responsive layout with adaptive components.
|
||||||
- **⚡ Real-time Social**: Instant messaging, reactions, and user profiles with social walls.
|
- **⚡ Real-time Social**: Instant messaging, reactions, and user profiles with social walls.
|
||||||
- **🚦 Transaction Lifecycle**: Simulated blockchain transaction states (Pending, Validated, Failed) with LED indicators.
|
- **🚦 Transaction Lifecycle**: Simulated blockchain transaction states (Pending, Validated, Failed).
|
||||||
- **🛠 Robust Tooling**: Automated linting, testing, and a custom internal task tracker.
|
|
||||||
|
|
||||||
## 🛠 Tech Stack
|
## 🛠 Tech Stack
|
||||||
|
|
||||||
@@ -23,12 +24,13 @@ Plexus is a premium, decentralized-inspired chat application built with **Vue 3*
|
|||||||
|
|
||||||
Explore our detailed documentation in the `docs/` directory:
|
Explore our detailed documentation in the `docs/` directory:
|
||||||
|
|
||||||
- [🏗 Architecture](file:///home/sinan/Documents/repositories/local/plexus/docs/architecture.md) - High-level system design.
|
- [O Vision](./docs/vision.md) - Platform vision.
|
||||||
- [📂 Structure](file:///home/sinan/Documents/repositories/local/plexus/docs/structure.md) - Directory and file organization.
|
- [🏗 Architecture](./docs/architecture.md) - High-level system design.
|
||||||
- [⚙️ Functions & API](file:///home/sinan/Documents/repositories/local/plexus/docs/functions.md) - Socket events and backend logic.
|
- [📂 Structure](./docs/structure.md) - Directory and file organization.
|
||||||
- [📊 Data Model](file:///home/sinan/Documents/repositories/local/plexus/docs/data-model.md) - Database schema and migrations.
|
- [⚙️ Functions & API](./docs/functions.md) - Socket events and backend logic.
|
||||||
- [📈 Scalability](file:///home/sinan/Documents/repositories/local/plexus/docs/scalability.md) - Future roadmap and scaling strategies.
|
- [📊 Data Model](./docs/data-model.md) - Database schema and migrations.
|
||||||
- [📝 Task Tracker](file:///home/sinan/Documents/repositories/local/plexus/docs/tasks.md) - How to use the internal task management tool.
|
- [📈 Scalability](./docs/scalability.md) - Future roadmap and scaling strategies.
|
||||||
|
- [📝 Task Tracker](./docs/tasks.md) - How to use the internal task management tool.
|
||||||
|
|
||||||
## 🚦 Quick Start
|
## 🚦 Quick Start
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ cd client && npm run dev
|
|||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 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.
|
1. **Pick a Task**: Use `make task-list` to find something to work on.
|
||||||
2. **Code**: Implement your changes.
|
2. **Code**: Implement your changes.
|
||||||
|
|||||||
@@ -11,13 +11,17 @@ server {
|
|||||||
|
|
||||||
# Proxy API requests to backend
|
# Proxy API requests to backend
|
||||||
location /socket.io/ {
|
location /socket.io/ {
|
||||||
proxy_pass http://server:3000;
|
proxy_pass http://api:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1533
client/package-lock.json
generated
1533
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,15 +17,19 @@
|
|||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"vue": "^3.5.24"
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-vue": "^9.21.1",
|
"eslint-plugin-vue": "^9.21.1",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useChatStore } from './stores/chat';
|
import { useChatStore } from './stores/chat';
|
||||||
import WalletConnect from './components/WalletConnect.vue';
|
|
||||||
import ChatLayout from './components/ChatLayout.vue';
|
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const videoRef = ref(null);
|
const videoRef = ref(null);
|
||||||
@@ -34,11 +32,7 @@ const handleMuteToggle = () => {
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="relative z-10 h-full">
|
<div class="relative z-10 h-full">
|
||||||
<WalletConnect v-if="!chatStore.isConnected" />
|
<router-view @toggle-mute="handleMuteToggle" />
|
||||||
<ChatLayout
|
|
||||||
v-else
|
|
||||||
@toggle-mute="handleMuteToggle"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div class="flex h-screen w-full overflow-hidden relative bg-discord-dark">
|
<div class="flex h-screen w-full overflow-hidden relative bg-discord-dark">
|
||||||
<!-- Mobile Menu Overlay -->
|
<!-- Mobile Menu Overlay -->
|
||||||
@@ -43,6 +7,42 @@ const saveSettings = () => {
|
|||||||
@click="showMobileMenu = false"
|
@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 -->
|
<!-- Settings Modal -->
|
||||||
<div
|
<div
|
||||||
v-if="showSettings"
|
v-if="showSettings"
|
||||||
@@ -70,6 +70,16 @@ const saveSettings = () => {
|
|||||||
placeholder="Enter new username"
|
placeholder="Enter new username"
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-xs font-bold text-crypto-muted uppercase tracking-wider mb-2">Wallet Address</label>
|
<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">
|
<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">
|
<h1 class="font-bold text-white truncate">
|
||||||
Plexus Server
|
Plexus Server
|
||||||
</h1>
|
</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>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-3 space-y-0.5 px-2">
|
<div class="flex-1 overflow-y-auto py-3 space-y-0.5 px-2">
|
||||||
@@ -125,7 +122,7 @@ const saveSettings = () => {
|
|||||||
<button
|
<button
|
||||||
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group mb-4',
|
: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']"
|
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
|
<User
|
||||||
size="18"
|
size="18"
|
||||||
@@ -134,17 +131,37 @@ const saveSettings = () => {
|
|||||||
<span class="text-sm font-medium">My Profile</span>
|
<span class="text-sm font-medium">My Profile</span>
|
||||||
</button>
|
</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">
|
<div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
||||||
Text Channels
|
Text Channels
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="channel in channels"
|
v-for="channel in textChannels"
|
||||||
:key="channel.id"
|
:key="channel.id"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group',
|
: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']"
|
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
|
<Hash
|
||||||
size="18"
|
size="18"
|
||||||
@@ -153,34 +170,68 @@ const saveSettings = () => {
|
|||||||
<span class="text-sm font-medium">{{ channel.name }}</span>
|
<span class="text-sm font-medium">{{ channel.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Music Player & Profile -->
|
<!-- Music Player & Profile -->
|
||||||
<div class="bg-discord-black p-2 space-y-2">
|
<div class="bg-discord-black p-2 space-y-2">
|
||||||
<MusicPlayer />
|
<MusicPlayer :channel="currentChannel" />
|
||||||
|
|
||||||
<div
|
<div class="flex items-center gap-2 p-1.5 rounded-md hover:bg-[#35373c] transition-all group">
|
||||||
class="flex items-center gap-2 p-1.5 rounded-md hover:bg-[#35373c] transition-all group cursor-pointer"
|
<div
|
||||||
@click="showSettings = true"
|
class="relative 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
|
||||||
|
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() }}
|
{{ username?.substring(0, 2).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-discord-black rounded-full" />
|
<div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-discord-black rounded-full" />
|
||||||
</div>
|
</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">
|
<div class="text-xs font-bold text-white truncate">
|
||||||
{{ username }}
|
{{ username }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-gray-400 truncate">
|
<div class="text-[10px] text-gray-400 truncate">
|
||||||
#{{ walletAddress?.slice(-4) }}
|
#{{ walletAddress?.slice(-4) }} • <span class="text-yellow-400">{{ chatStore.balance }} $PLEXUS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Settings
|
<button
|
||||||
size="14"
|
class="text-gray-400 hover:text-red-400 transition-colors p-1"
|
||||||
class="text-gray-400 group-hover:text-gray-200"
|
title="Logout"
|
||||||
/>
|
@click="logout"
|
||||||
|
>
|
||||||
|
<LogOut size="16" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,18 +239,52 @@ const saveSettings = () => {
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="flex-1 flex flex-col bg-discord-dark relative overflow-hidden">
|
<div class="flex-1 flex flex-col bg-discord-dark relative overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- 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
|
<button
|
||||||
class="md:hidden mr-3 text-gray-400 hover:text-white transition-colors"
|
v-if="!showProfile && !viewDocs && !viewChangelog && activeTab === 'chat'"
|
||||||
@click="showMobileMenu = true"
|
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>
|
</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>
|
||||||
|
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex-1 flex overflow-hidden">
|
||||||
@@ -208,17 +293,220 @@ const saveSettings = () => {
|
|||||||
v-if="showProfile"
|
v-if="showProfile"
|
||||||
:address="selectedProfileAddress"
|
:address="selectedProfileAddress"
|
||||||
/>
|
/>
|
||||||
<MessageList
|
<div v-else-if="viewDocs" class="flex-1 overflow-y-auto custom-scrollbar p-4 md:p-8 flex flex-col items-center">
|
||||||
v-else
|
<DocsView class="w-full max-w-4xl" />
|
||||||
@view-profile="(addr) => { selectedProfileAddress = addr; showProfile = true; }"
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Member List (Discord Style) -->
|
<!-- Member List (Discord Style) -->
|
||||||
<div class="w-60 bg-discord-sidebar border-l border-black/20 hidden xl:flex flex-col">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 { ref, onUpdated, nextTick } from 'vue';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
import { storeToRefs } from 'pinia';
|
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 chatStore = useChatStore();
|
||||||
const { currentMessages, currentChannel, walletAddress } = storeToRefs(chatStore);
|
const { currentMessages, currentChannel, walletAddress } = storeToRefs(chatStore);
|
||||||
const newMessage = ref('');
|
const newMessage = ref('');
|
||||||
const messagesContainer = ref(null);
|
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) => {
|
const toggleReaction = (messageId, emoji) => {
|
||||||
|
console.log('Toggling reaction:', messageId, emoji);
|
||||||
chatStore.toggleReaction(messageId, emoji);
|
chatStore.toggleReaction(messageId, emoji);
|
||||||
showEmojiPicker.value = null;
|
showEmojiPicker.value = null;
|
||||||
};
|
};
|
||||||
@@ -51,18 +53,21 @@ const formatTime = (isoString) => {
|
|||||||
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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']);
|
const emit = defineEmits(['view-profile']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 flex flex-col h-full bg-discord-dark relative z-10">
|
<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="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">
|
<div class="text-sm font-bold text-white flex items-center gap-2">
|
||||||
<Hash
|
<Hash size="18" class="text-gray-400" />
|
||||||
size="18"
|
|
||||||
class="text-gray-400"
|
|
||||||
/>
|
|
||||||
{{ currentChannel }}
|
{{ currentChannel }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,9 +93,12 @@ const emit = defineEmits(['view-profile']);
|
|||||||
<div
|
<div
|
||||||
v-for="(msg, index) in currentMessages"
|
v-for="(msg, index) in currentMessages"
|
||||||
:key="msg.id || msg.tempId"
|
: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 class="w-10 flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
|
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 class="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
|
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
|
<span
|
||||||
:class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']"
|
: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>
|
||||||
<span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</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>
|
||||||
|
|
||||||
<div :class="['text-sm leading-relaxed break-words', msg.status === 'failed' ? 'text-status-failed line-through opacity-60' : 'text-gray-100']">
|
<!-- Message Content & Status -->
|
||||||
{{ msg.content }}
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Reactions Display -->
|
<!-- Reactions Display -->
|
||||||
<div
|
<div
|
||||||
v-if="msg.reactions && msg.reactions.length > 0"
|
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
|
<button
|
||||||
v-for="emoji in getUniqueEmojis(msg.reactions)"
|
v-for="emoji in getUniqueEmojis(msg.reactions)"
|
||||||
:key="emoji"
|
: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)
|
hasUserReacted(msg.reactions, emoji)
|
||||||
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
|
? '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']"
|
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:border-white/20']"
|
||||||
@click="toggleReaction(msg.id, emoji)"
|
@click="toggleReaction(msg.id, emoji)"
|
||||||
>
|
>
|
||||||
<span>{{ emoji }}</span>
|
<span>{{ emoji }}</span>
|
||||||
@@ -161,11 +195,11 @@ const emit = defineEmits(['view-profile']);
|
|||||||
|
|
||||||
<!-- Hover Actions -->
|
<!-- Hover Actions -->
|
||||||
<div
|
<div
|
||||||
v-if="msg.status !== 'failed'"
|
v-if="msg.status !== 'failed' && msg.id"
|
||||||
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"
|
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
|
<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"
|
title="Add Reaction"
|
||||||
@click="showEmojiPicker = showEmojiPicker === msg.id ? null : msg.id"
|
@click="showEmojiPicker = showEmojiPicker === msg.id ? null : msg.id"
|
||||||
>
|
>
|
||||||
@@ -180,7 +214,7 @@ const emit = defineEmits(['view-profile']);
|
|||||||
<button
|
<button
|
||||||
v-for="emoji in EMOJIS"
|
v-for="emoji in EMOJIS"
|
||||||
:key="emoji"
|
: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)"
|
@click="toggleReaction(msg.id, emoji)"
|
||||||
>
|
>
|
||||||
{{ emoji }}
|
{{ emoji }}
|
||||||
@@ -191,8 +225,8 @@ const emit = defineEmits(['view-profile']);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div class="p-4 bg-discord-dark">
|
<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">
|
<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
|
<input
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -201,21 +235,21 @@ const emit = defineEmits(['view-profile']);
|
|||||||
@keyup.enter="send"
|
@keyup.enter="send"
|
||||||
>
|
>
|
||||||
<button
|
<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"
|
@click="send"
|
||||||
>
|
>
|
||||||
<Send size="20" />
|
<Send size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center gap-4 px-1">
|
<div class="mt-3 flex items-center gap-6 px-1">
|
||||||
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
|
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
|
||||||
<div class="led led-orange w-1.5 h-1.5" /> Pending
|
<div class="led led-orange w-2 h-2 animate-pulse" /> Pending
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
|
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
|
||||||
<div class="led led-green w-1.5 h-1.5" /> Validated
|
<div class="led led-green w-2 h-2" /> Validated
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
|
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
|
||||||
<div class="led led-red w-1.5 h-1.5" /> Failed
|
<div class="led led-red w-2 h-2" /> Failed (not saved)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,13 +265,4 @@ const emit = defineEmits(['view-profile']);
|
|||||||
0% { transform: scale(0.5); opacity: 0; }
|
0% { transform: scale(0.5); opacity: 0; }
|
||||||
100% { transform: scale(1); opacity: 1; }
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { Play, Pause, SkipForward, Volume2, VolumeX, Music } from 'lucide-vue-next';
|
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 isPlaying = ref(false);
|
||||||
const isMuted = ref(false);
|
const isMuted = ref(false);
|
||||||
const volume = ref(50);
|
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);
|
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 = () => {
|
const togglePlay = () => {
|
||||||
isPlaying.value = !isPlaying.value;
|
isPlaying.value = !isPlaying.value;
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
@@ -25,10 +41,6 @@ const togglePlay = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextTrack = () => {
|
|
||||||
currentVideoIndex.value = (currentVideoIndex.value + 1) % LOFI_VIDEOS.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
isMuted.value = !isMuted.value;
|
isMuted.value = !isMuted.value;
|
||||||
const vol = isMuted.value ? 0 : volume.value;
|
const vol = isMuted.value ? 0 : volume.value;
|
||||||
@@ -43,72 +55,56 @@ const updateVolume = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 bg-white/5 border border-white/10 rounded-2xl backdrop-blur-md shadow-xl">
|
<div class="p-3 bg-white/5 border border-white/10 rounded-xl backdrop-blur-md">
|
||||||
<div class="flex items-center gap-4">
|
<!-- Hidden YouTube Player -->
|
||||||
<!-- Hidden YouTube Player -->
|
<iframe
|
||||||
<iframe
|
ref="player"
|
||||||
ref="player"
|
class="hidden"
|
||||||
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`"
|
||||||
: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"
|
||||||
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">
|
<div class="flex items-center gap-3">
|
||||||
<Music size="24" />
|
<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>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-sm font-bold text-white truncate">
|
<div class="text-xs font-bold text-white truncate">
|
||||||
Lofi Radio
|
{{ currentPlaylist.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-crypto-muted uppercase tracking-wider">
|
<div class="text-[10px] text-crypto-muted uppercase tracking-wider">
|
||||||
Chilling in the Nebula
|
#{{ channel }} Radio
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<button
|
||||||
<button
|
class="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all"
|
||||||
class="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all"
|
@click="togglePlay"
|
||||||
@click="togglePlay"
|
>
|
||||||
>
|
<Pause v-if="isPlaying" size="16" />
|
||||||
<Pause
|
<Play v-else size="16" />
|
||||||
v-if="isPlaying"
|
</button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-3">
|
<!-- Volume Control -->
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="text-gray-400 hover:text-white transition-colors"
|
class="text-gray-400 hover:text-white transition-colors"
|
||||||
@click="toggleMute"
|
@click="toggleMute"
|
||||||
>
|
>
|
||||||
<VolumeX
|
<VolumeX v-if="isMuted || volume == 0" size="14" />
|
||||||
v-if="isMuted || volume == 0"
|
<Volume2 v-else size="14" />
|
||||||
size="16"
|
|
||||||
/>
|
|
||||||
<Volume2
|
|
||||||
v-else
|
|
||||||
size="16"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
v-model="volume"
|
v-model="volume"
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
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"
|
@input="updateVolume"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,9 +114,9 @@ const updateVolume = () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
input[type='range']::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 12px;
|
width: 10px;
|
||||||
height: 12px;
|
height: 10px;
|
||||||
background: #6366f1;
|
background: #8b5cf6;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { MessageSquare } from 'lucide-vue-next';
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
|
const { onlineUsers, offlineUsers, walletAddress } = storeToRefs(chatStore);
|
||||||
|
|
||||||
const emit = defineEmits(['view-profile']);
|
const emit = defineEmits(['view-profile']);
|
||||||
|
|
||||||
|
const startDM = (e, targetWallet) => {
|
||||||
|
e.stopPropagation(); // Prevent opening profile
|
||||||
|
chatStore.startDM(targetWallet);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,7 +26,7 @@ const emit = defineEmits(['view-profile']);
|
|||||||
<div
|
<div
|
||||||
v-for="user in onlineUsers"
|
v-for="user in onlineUsers"
|
||||||
:key="user.wallet_address"
|
: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)"
|
@click="emit('view-profile', user.wallet_address)"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -34,6 +40,16 @@ const emit = defineEmits(['view-profile']);
|
|||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +63,7 @@ const emit = defineEmits(['view-profile']);
|
|||||||
<div
|
<div
|
||||||
v-for="user in offlineUsers"
|
v-for="user in offlineUsers"
|
||||||
:key="user.wallet_address"
|
: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)"
|
@click="emit('view-profile', user.wallet_address)"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -61,6 +77,16 @@ const emit = defineEmits(['view-profile']);
|
|||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue';
|
import { ref, onMounted, watch, computed } from 'vue';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
import { storeToRefs } from 'pinia';
|
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({
|
const props = defineProps({
|
||||||
address: {
|
address: {
|
||||||
@@ -12,12 +12,20 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const { profileUser, profilePosts, isProfileLoading, walletAddress } = storeToRefs(chatStore);
|
const { profileUser, profilePosts, profileReposts, isProfileLoading, walletAddress, balance } = storeToRefs(chatStore);
|
||||||
|
|
||||||
const isEditing = ref(false);
|
const isEditing = ref(false);
|
||||||
const editBio = ref('');
|
const editBio = ref('');
|
||||||
const editBannerColor = ref('');
|
const editBannerColor = ref('');
|
||||||
|
const editUsername = ref('');
|
||||||
const newPostContent = 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 = () => {
|
const loadProfile = () => {
|
||||||
chatStore.getProfile(props.address);
|
chatStore.getProfile(props.address);
|
||||||
@@ -29,10 +37,14 @@ watch(() => props.address, loadProfile);
|
|||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
editBio.value = profileUser.value.bio || '';
|
editBio.value = profileUser.value.bio || '';
|
||||||
editBannerColor.value = profileUser.value.banner_color || '#6366f1';
|
editBannerColor.value = profileUser.value.banner_color || '#6366f1';
|
||||||
|
editUsername.value = profileUser.value.username || '';
|
||||||
isEditing.value = true;
|
isEditing.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveProfile = () => {
|
const saveProfile = () => {
|
||||||
|
if (editUsername.value && editUsername.value !== profileUser.value.username) {
|
||||||
|
chatStore.updateUsername(editUsername.value);
|
||||||
|
}
|
||||||
chatStore.updateProfile(editBio.value, editBannerColor.value);
|
chatStore.updateProfile(editBio.value, editBannerColor.value);
|
||||||
isEditing.value = false;
|
isEditing.value = false;
|
||||||
};
|
};
|
||||||
@@ -43,10 +55,52 @@ const submitPost = () => {
|
|||||||
newPostContent.value = '';
|
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 formatTime = (isoString) => {
|
||||||
const date = new Date(isoString);
|
const date = new Date(isoString);
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -67,8 +121,9 @@ const formatTime = (isoString) => {
|
|||||||
class="h-48 w-full relative transition-colors duration-500"
|
class="h-48 w-full relative transition-colors duration-500"
|
||||||
:style="{ backgroundColor: profileUser.banner_color }"
|
: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="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() }}
|
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,28 +136,82 @@ const formatTime = (isoString) => {
|
|||||||
<h1 class="text-3xl font-bold text-white">
|
<h1 class="text-3xl font-bold text-white">
|
||||||
{{ profileUser.username }}
|
{{ profileUser.username }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-400 font-mono text-sm mt-1">
|
<button
|
||||||
{{ profileUser.wallet_address }}
|
class="flex items-center gap-2 text-gray-400 font-mono text-sm mt-1 hover:text-violet-400 transition-colors group"
|
||||||
</p>
|
@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>
|
</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>
|
||||||
|
|
||||||
<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...' }}
|
{{ profileUser.bio || 'No bio yet...' }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Wall / Posts -->
|
<!-- Wall / Posts -->
|
||||||
@@ -116,8 +225,8 @@ const formatTime = (isoString) => {
|
|||||||
|
|
||||||
<!-- New Post Input (only for owner) -->
|
<!-- New Post Input (only for owner) -->
|
||||||
<div
|
<div
|
||||||
v-if="profileUser.wallet_address === walletAddress"
|
v-if="isOwnProfile"
|
||||||
class="mb-8 bg-discord-sidebar/30 rounded-2xl p-4 border border-white/5"
|
class="mb-8 bg-discord-sidebar/30 rounded-2xl p-4 border border-white/5 focus-within:border-violet-500/30 transition-all"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newPostContent"
|
v-model="newPostContent"
|
||||||
@@ -126,7 +235,7 @@ const formatTime = (isoString) => {
|
|||||||
/>
|
/>
|
||||||
<div class="flex justify-end mt-2 pt-2 border-t border-white/5">
|
<div class="flex justify-end mt-2 pt-2 border-t border-white/5">
|
||||||
<button
|
<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"
|
@click="submitPost"
|
||||||
>
|
>
|
||||||
Post <Send size="16" />
|
Post <Send size="16" />
|
||||||
@@ -145,11 +254,11 @@ const formatTime = (isoString) => {
|
|||||||
<div
|
<div
|
||||||
v-for="post in profilePosts"
|
v-for="post in profilePosts"
|
||||||
:key="post.id"
|
: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 justify-between items-start mb-3">
|
||||||
<div class="flex items-center gap-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() }}
|
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -161,10 +270,103 @@ const formatTime = (isoString) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div class="text-gray-200 leading-relaxed">
|
<div class="text-gray-200 leading-relaxed mb-4">
|
||||||
{{ post.content }}
|
{{ post.content }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +390,15 @@ const formatTime = (isoString) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-6">
|
<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>
|
<div>
|
||||||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bio</label>
|
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bio</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const isConnecting = ref(false);
|
const isConnecting = ref(false);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
@@ -26,6 +28,9 @@ const connectWallet = async () => {
|
|||||||
// Simple username generation or prompt
|
// Simple username generation or prompt
|
||||||
const username = wallet.slice(0, 4) + '...' + wallet.slice(-4);
|
const username = wallet.slice(0, 4) + '...' + wallet.slice(-4);
|
||||||
chatStore.connect(wallet, username, signature);
|
chatStore.connect(wallet, username, signature);
|
||||||
|
|
||||||
|
// Redirect to chat after successful login
|
||||||
|
router.push('/chat/nebula');
|
||||||
} else {
|
} else {
|
||||||
alert('Solana object not found! Get a Phantom Wallet 👻');
|
alert('Solana object not found! Get a Phantom Wallet 👻');
|
||||||
window.open('https://phantom.app/', '_blank');
|
window.open('https://phantom.app/', '_blank');
|
||||||
@@ -41,29 +46,54 @@ const connectWallet = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center justify-center h-screen bg-black/50 backdrop-blur-sm">
|
<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">
|
<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">
|
||||||
<h1 class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-400 to-pink-600 text-transparent bg-clip-text">
|
<!-- Logo -->
|
||||||
Crypto Chat
|
<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>
|
</h1>
|
||||||
<p class="text-crypto-muted mb-8">
|
<p class="text-crypto-muted mb-8 text-sm">
|
||||||
Connect your wallet to join the conversation.
|
Web3 Social Chat • Connect wallet to join
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
:disabled="isConnecting"
|
: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"
|
@click="connectWallet"
|
||||||
>
|
>
|
||||||
<span v-if="isConnecting">Connecting...</span>
|
<span v-if="isConnecting" class="flex items-center gap-2">
|
||||||
<span v-else>Connect Phantom Wallet</span>
|
<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>
|
</button>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="error"
|
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 }}
|
{{ error }}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { createPinia } from 'pinia'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
app.mount('#app')
|
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;
|
||||||
115
client/src/stores/__tests__/chat.spec.js
Normal file
115
client/src/stores/__tests__/chat.spec.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import { useChatStore } from '../chat';
|
||||||
|
|
||||||
|
// Mock socket.io-client
|
||||||
|
const mockSocket = {
|
||||||
|
on: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
connected: true
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('socket.io-client', () => ({
|
||||||
|
io: () => mockSocket
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Reset socket mocks
|
||||||
|
mockSocket.on.mockReset();
|
||||||
|
mockSocket.emit.mockReset();
|
||||||
|
localStorageMock.clear();
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = vi.fn(() => Promise.resolve({
|
||||||
|
json: () => Promise.resolve([])
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with default balance of 100', () => {
|
||||||
|
const store = useChatStore();
|
||||||
|
expect(store.balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduct 1 PLEXUS when sending a message', () => {
|
||||||
|
const store = useChatStore();
|
||||||
|
store.connect('wallet123', 'user123');
|
||||||
|
|
||||||
|
// Mock socket connection
|
||||||
|
const connectCallback = mockSocket.on.mock.calls.find(call => call[0] === 'connect')[1];
|
||||||
|
connectCallback();
|
||||||
|
|
||||||
|
const initialBalance = store.balance;
|
||||||
|
store.sendMessage('Hello');
|
||||||
|
|
||||||
|
expect(store.balance).toBe(initialBalance - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent sending message if balance is insufficient', () => {
|
||||||
|
const store = useChatStore();
|
||||||
|
store.connect('wallet123', 'user123');
|
||||||
|
store.balance = 0;
|
||||||
|
|
||||||
|
// Mock alert
|
||||||
|
window.alert = vi.fn();
|
||||||
|
|
||||||
|
store.sendMessage('Hello');
|
||||||
|
|
||||||
|
expect(store.balance).toBe(0);
|
||||||
|
expect(mockSocket.emit).not.toHaveBeenCalledWith('sendMessage', expect.anything());
|
||||||
|
expect(window.alert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update balance when balanceUpdated event is received', () => {
|
||||||
|
const store = useChatStore();
|
||||||
|
store.connect('wallet123', 'user123');
|
||||||
|
|
||||||
|
// Find the balanceUpdated handler
|
||||||
|
// We need to trigger the socket.on call that registers the handler
|
||||||
|
// The store calls socket.on multiple times. We need to find the one for 'balanceUpdated'
|
||||||
|
|
||||||
|
// Since we mocked socket.on, we can simulate the event
|
||||||
|
// But the store registers listeners inside `connect`
|
||||||
|
|
||||||
|
// Get all calls to socket.on
|
||||||
|
const calls = mockSocket.on.mock.calls;
|
||||||
|
const balanceHandler = calls.find(call => call[0] === 'balanceUpdated')[1];
|
||||||
|
|
||||||
|
expect(balanceHandler).toBeDefined();
|
||||||
|
|
||||||
|
// Simulate event
|
||||||
|
balanceHandler({ balance: 50 });
|
||||||
|
|
||||||
|
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 { defineStore } from 'pinia';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import Cookies from 'js-cookie';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
export const useChatStore = defineStore('chat', () => {
|
||||||
const socket = ref(null);
|
const socket = ref(null);
|
||||||
@@ -9,6 +9,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const walletAddress = ref(null);
|
const walletAddress = ref(null);
|
||||||
const username = ref(null);
|
const username = ref(null);
|
||||||
const signature = ref(null);
|
const signature = ref(null);
|
||||||
|
const balance = ref(parseInt(localStorage.getItem('plexus_balance')) || 100); // Init from storage or default 100
|
||||||
|
|
||||||
const currentChannel = ref('nebula');
|
const currentChannel = ref('nebula');
|
||||||
const messages = ref({}); // { channelId: [messages] }
|
const messages = ref({}); // { channelId: [messages] }
|
||||||
@@ -18,7 +19,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
// Profile state
|
// Profile state
|
||||||
const profileUser = ref(null);
|
const profileUser = ref(null);
|
||||||
const profilePosts = ref([]);
|
const profilePosts = ref([]);
|
||||||
|
const profileReposts = ref([]);
|
||||||
const isProfileLoading = ref(false);
|
const isProfileLoading = ref(false);
|
||||||
|
const profilePicture = ref(localStorage.getItem('plexus_nft_pic') || null);
|
||||||
|
|
||||||
const onlineUsers = computed(() => users.value.filter(u => u.online));
|
const onlineUsers = computed(() => users.value.filter(u => u.online));
|
||||||
const offlineUsers = computed(() => users.value.filter(u => !u.online));
|
const offlineUsers = computed(() => users.value.filter(u => !u.online));
|
||||||
@@ -30,7 +33,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
username.value = name;
|
username.value = name;
|
||||||
if (sig) {
|
if (sig) {
|
||||||
signature.value = 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)
|
// Connect to same origin (proxied by Vite in dev, Nginx in prod)
|
||||||
@@ -51,17 +54,22 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
messages.value[message.channelId] = [];
|
messages.value[message.channelId] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this message matches a local pending message (by content and wallet)
|
// Check if this message matches a local pending message (by txId)
|
||||||
// In a real app, we'd use the txId to match
|
|
||||||
const pendingIdx = messages.value[message.channelId].findIndex(
|
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) {
|
if (pendingIdx !== -1) {
|
||||||
// Update the pending message with server data and mark as validated
|
// Update the pending message with server data and mark as validated
|
||||||
messages.value[message.channelId][pendingIdx] = { ...message, status: 'validated' };
|
messages.value[message.channelId][pendingIdx] = { ...message, status: 'validated' };
|
||||||
} else {
|
} 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' }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,39 +78,98 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.value.on('updateReactions', ({ messageId, reactions }) => {
|
socket.value.on('updateReactions', ({ messageId, reactions }) => {
|
||||||
|
console.log('Received updateReactions for message:', messageId, 'reactions:', reactions);
|
||||||
for (const channelId in messages.value) {
|
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) {
|
if (msg) {
|
||||||
msg.reactions = reactions;
|
msg.reactions = reactions;
|
||||||
|
// Trigger reactivity by replacing the array
|
||||||
|
messages.value[channelId] = [...messages.value[channelId]];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.value.on('profileData', (data) => {
|
socket.value.on('profileData', (data) => {
|
||||||
profileUser.value = data;
|
console.log('Profile data received:', data);
|
||||||
profilePosts.value = data.posts;
|
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;
|
isProfileLoading.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.value.on('profileUpdated', (data) => {
|
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 };
|
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) => {
|
socket.value.on('postCreated', (post) => {
|
||||||
profilePosts.value = [post, ...profilePosts.value];
|
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 }) => {
|
socket.value.on('usernameUpdated', ({ username: newName }) => {
|
||||||
username.value = newName;
|
username.value = newName;
|
||||||
const savedAuth = Cookies.get('plexus_auth');
|
saveSession(walletAddress.value, newName, signature.value);
|
||||||
if (savedAuth) {
|
});
|
||||||
const authData = JSON.parse(savedAuth);
|
|
||||||
authData.name = newName;
|
socket.value.on('balanceUpdated', ({ balance: newBalance }) => {
|
||||||
Cookies.set('plexus_auth', JSON.stringify(authData), { expires: 7 });
|
balance.value = newBalance;
|
||||||
}
|
localStorage.setItem('plexus_balance', newBalance.toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.value.on('error', (err) => {
|
socket.value.on('error', (err) => {
|
||||||
@@ -119,7 +186,18 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
function sendMessage(content) {
|
function sendMessage(content) {
|
||||||
if (!socket.value || !content.trim()) return;
|
if (!socket.value || !content.trim()) return;
|
||||||
|
|
||||||
|
if (balance.value < 1) {
|
||||||
|
alert('Insufficient $PLEXUS balance! You need 1 $PLEXUS to send a message.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct token immediately for UI feedback
|
||||||
|
balance.value -= 1;
|
||||||
|
|
||||||
const tempId = 'temp-' + Date.now();
|
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 = {
|
const pendingMsg = {
|
||||||
tempId,
|
tempId,
|
||||||
channelId: currentChannel.value,
|
channelId: currentChannel.value,
|
||||||
@@ -128,19 +206,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
content,
|
content,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
txId: mockTxId,
|
||||||
reactions: []
|
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(() => {
|
setTimeout(() => {
|
||||||
// Randomly fail 5% of the time for demonstration
|
// Randomly fail 5% of the time for demonstration
|
||||||
const failed = Math.random() < 0.05;
|
const failed = Math.random() < 0.05;
|
||||||
@@ -172,6 +241,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
console.log('Simulating 1 $PLEXUS transaction for reaction...');
|
console.log('Simulating 1 $PLEXUS transaction for reaction...');
|
||||||
|
|
||||||
socket.value.emit('toggleReaction', {
|
socket.value.emit('toggleReaction', {
|
||||||
|
channelId: currentChannel.value,
|
||||||
messageId,
|
messageId,
|
||||||
walletAddress: walletAddress.value,
|
walletAddress: walletAddress.value,
|
||||||
emoji
|
emoji
|
||||||
@@ -215,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) {
|
function setChannel(channelId) {
|
||||||
currentChannel.value = channelId;
|
currentChannel.value = channelId;
|
||||||
if (!messages.value[channelId]) {
|
if (!messages.value[channelId]) {
|
||||||
@@ -225,7 +335,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
async function fetchChannels() {
|
async function fetchChannels() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/channels');
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch channels', e);
|
console.error('Failed to fetch channels', e);
|
||||||
}
|
}
|
||||||
@@ -235,12 +348,66 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/messages/${channelId}`);
|
const res = await fetch(`/api/messages/${channelId}`);
|
||||||
const data = await res.json();
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch messages', 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 {
|
return {
|
||||||
socket,
|
socket,
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -255,7 +422,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
currentMessages,
|
currentMessages,
|
||||||
profileUser,
|
profileUser,
|
||||||
profilePosts,
|
profilePosts,
|
||||||
|
profileReposts,
|
||||||
isProfileLoading,
|
isProfileLoading,
|
||||||
|
profilePicture,
|
||||||
connect,
|
connect,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
toggleReaction,
|
toggleReaction,
|
||||||
@@ -263,6 +432,14 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
getProfile,
|
getProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
createPost,
|
createPost,
|
||||||
setChannel
|
createComment,
|
||||||
|
fetchComments,
|
||||||
|
repost,
|
||||||
|
startDM,
|
||||||
|
setChannel,
|
||||||
|
setProfilePicture,
|
||||||
|
balance,
|
||||||
|
checkAuth,
|
||||||
|
logout
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
server:
|
api:
|
||||||
build: ./server
|
build: ./server
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./server:/app
|
||||||
environment:
|
environment:
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
client:
|
client:
|
||||||
build: ./client
|
build: ./client
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- api
|
||||||
|
volumes:
|
||||||
|
- ./client:/app
|
||||||
|
|
||||||
dev-shell:
|
dev-shell:
|
||||||
build:
|
build:
|
||||||
|
|||||||
34
docs/vision.md
Normal file
34
docs/vision.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Vision
|
||||||
|
|
||||||
|
## Une plateforme pour tous
|
||||||
|
|
||||||
|
Le but de Nexus est de créer un environnement entre le chat youtube (ou plein de gens discutent temporairement et ne se connaissant pas) et un réseau social (ou les gens n'ont pas de place pour discuter vraiment), qui s'appuie sur la blockchain pour la confidentialité et des evenements numériques cool.
|
||||||
|
Le but est de faire de vrai salons numériques, des clubs 2.0, ou les utilisateurs se rencontrent et sont amenés à se connaitre.
|
||||||
|
|
||||||
|
Une musique vidéo est jouée en fond (differents styles selon les salons) pour l'aspect cosy et etre sur que tout le monde ecoute la meme chose.
|
||||||
|
|
||||||
|
## Un fonctionnement décentralisé
|
||||||
|
|
||||||
|
La plateforme requiert un login via phantom (+ de wallet à venir) avec une addrese Solana. La platfeforme utilise un token $PLEXUS pour fonctionner. Envoyer un message coute 1$PLEXUS (cout bas mais trace sur la blockchain), chaque message contient ainsi une transaction id qui vérifie que le user à bien dépensé 1$PLEXUS pour envoyer son message.
|
||||||
|
|
||||||
|
Changer de username coute 30$PLEXUS et les utilsateurs peuvent choisir une photo de profil NFT.
|
||||||
|
|
||||||
|
|
||||||
|
## Des salons détenus par les membres
|
||||||
|
|
||||||
|
Le salon doit etre la propriété des membres. des règles / parametres du salon sont gérés par les membres. Chaque salon a donc ses propres règles. Ouvert ou fermé, nombre de membres max, qui est le ou les modérateurs (important pour que le site ne devienne pas un repaire de voleurs / arnaqueurs / criminels), la gouvernance (un chef qui décide, des membres qui votent, votent à la proportionnelle du token, ... ), Les règes du salon, le prix du membership (et le temps de membership, tous sont temporaires car si quelqu'un s'en va, la place ne reste pas bloquée) ..., on peut également imaginer un "pot commun" (récompensees d'evenements, stacker pour gagner des bonus (des emotes, des NFT, ...) )
|
||||||
|
|
||||||
|
Des evenements seront organisés, distribuant des $PLEXUS, des NFT, ... . L'aspect communautaire est important, l'aspect cool de la plateforme (l'image) est ULTRA importante.
|
||||||
|
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, ... .
|
||||||
|
|
||||||
|
|
||||||
|
## Pour aller plus loin
|
||||||
|
|
||||||
|
On peut imaginer un boutton IA qui peut lire les messages pour les résumer à la demande de l'utilisateur, ...
|
||||||
|
|
||||||
131
server/db.js
131
server/db.js
@@ -7,74 +7,89 @@ const db = new duckdb.Database(dbPath);
|
|||||||
const con = db.connect();
|
const con = db.connect();
|
||||||
|
|
||||||
// Initialize Schema
|
// Initialize Schema
|
||||||
con.exec(`
|
function initDb() {
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
return new Promise((resolve, reject) => {
|
||||||
wallet_address VARCHAR PRIMARY KEY,
|
try {
|
||||||
username VARCHAR UNIQUE,
|
con.exec(`
|
||||||
bio VARCHAR DEFAULT '',
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
banner_color VARCHAR DEFAULT '#6366f1',
|
wallet_address VARCHAR PRIMARY KEY,
|
||||||
last_seen TIMESTAMP
|
username VARCHAR UNIQUE,
|
||||||
);
|
bio VARCHAR DEFAULT '',
|
||||||
|
banner_color VARCHAR DEFAULT '#6366f1',
|
||||||
|
balance INTEGER DEFAULT 100,
|
||||||
|
last_seen TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
channel_id VARCHAR,
|
channel_id VARCHAR,
|
||||||
wallet_address VARCHAR,
|
wallet_address VARCHAR,
|
||||||
content VARCHAR,
|
content VARCHAR,
|
||||||
timestamp TIMESTAMP,
|
timestamp TIMESTAMP,
|
||||||
tx_id VARCHAR,
|
tx_id VARCHAR,
|
||||||
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
|
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reactions (
|
CREATE TABLE IF NOT EXISTS reactions (
|
||||||
message_id INTEGER,
|
message_id INTEGER,
|
||||||
wallet_address VARCHAR,
|
wallet_address VARCHAR,
|
||||||
emoji VARCHAR,
|
emoji VARCHAR,
|
||||||
PRIMARY KEY (message_id, wallet_address, emoji),
|
PRIMARY KEY (message_id, wallet_address, emoji),
|
||||||
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
|
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
wallet_address VARCHAR,
|
wallet_address VARCHAR,
|
||||||
content VARCHAR,
|
content VARCHAR,
|
||||||
timestamp TIMESTAMP,
|
timestamp TIMESTAMP,
|
||||||
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
|
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1;
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
CREATE SEQUENCE IF NOT EXISTS seq_post_id START 1;
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
-- Migration: Add tx_id to messages if it doesn't exist (for existing DBs)
|
CREATE TABLE IF NOT EXISTS reposts (
|
||||||
PRAGMA table_info('messages');
|
id INTEGER PRIMARY KEY,
|
||||||
`, (err) => {
|
post_id INTEGER,
|
||||||
if (err) return console.error('Schema error:', err);
|
wallet_address 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
|
CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1;
|
||||||
con.all("PRAGMA table_info('messages')", (err, rows) => {
|
CREATE SEQUENCE IF NOT EXISTS seq_post_id START 1;
|
||||||
if (err) return;
|
CREATE SEQUENCE IF NOT EXISTS seq_comment_id START 1;
|
||||||
const hasTxId = rows.some(r => r.name === 'tx_id');
|
CREATE SEQUENCE IF NOT EXISTS seq_repost_id START 1;
|
||||||
if (!hasTxId) {
|
|
||||||
con.run("ALTER TABLE messages ADD COLUMN tx_id VARCHAR", (err) => {
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_reposts_unique ON reposts(post_id, wallet_address);
|
||||||
if (err) console.error("Error adding tx_id column:", err);
|
`, (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
|
initDb().then(() => {
|
||||||
con.all("PRAGMA table_info('users')", (err, rows) => {
|
console.log('Database initialized successfully');
|
||||||
if (err) return;
|
}).catch(err => {
|
||||||
const hasBio = rows.some(r => r.name === 'bio');
|
console.error('Failed to initialize database (continuing anyway):', err);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('Database initialized and cleared');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { db, con };
|
module.exports = { db, con };
|
||||||
|
|||||||
444
server/index.js
444
server/index.js
@@ -50,14 +50,21 @@ io.on('connection', (socket) => {
|
|||||||
if (err) return console.error("Execute error:", err);
|
if (err) return console.error("Execute error:", err);
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
// User exists, update last seen
|
// User exists, update last seen and ensure balance
|
||||||
const existingUsername = rows[0].username;
|
const existingUser = rows[0];
|
||||||
con.prepare(`UPDATE users SET last_seen = ? WHERE wallet_address = ?`, (err, uStmt) => {
|
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);
|
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();
|
uStmt.finalize();
|
||||||
if (err) console.error("Update error:", err);
|
if (err) console.error("Update error:", err);
|
||||||
socket.emit('usernameUpdated', { username: existingUsername });
|
socket.emit('usernameUpdated', { username: existingUsername });
|
||||||
|
socket.emit('balanceUpdated', { balance: finalBalance });
|
||||||
broadcastUserList();
|
broadcastUserList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -74,12 +81,13 @@ io.on('connection', (socket) => {
|
|||||||
finalUsername = `${username}_${walletAddress.slice(0, 4)}`;
|
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);
|
if (err) return console.error("Prepare error:", err);
|
||||||
iStmt.run(walletAddress, finalUsername, now, (err) => {
|
iStmt.run(walletAddress, finalUsername, now, 100, (err) => {
|
||||||
iStmt.finalize();
|
iStmt.finalize();
|
||||||
if (err) console.error("Insert error:", err);
|
if (err) console.error("Insert error:", err);
|
||||||
socket.emit('usernameUpdated', { username: finalUsername });
|
socket.emit('usernameUpdated', { username: finalUsername });
|
||||||
|
socket.emit('balanceUpdated', { balance: 100 });
|
||||||
broadcastUserList();
|
broadcastUserList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -93,38 +101,63 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('updateUsername', ({ walletAddress, newUsername, txId }) => {
|
socket.on('updateUsername', ({ walletAddress, newUsername, txId }) => {
|
||||||
console.log(`User ${walletAddress} requesting username change to ${newUsername} (TX: ${txId})`);
|
console.log(`User ${walletAddress} requesting username change to ${newUsername} (TX: ${txId})`);
|
||||||
|
|
||||||
// Check if username is taken
|
// First check if user exists and has enough balance
|
||||||
con.prepare(`SELECT wallet_address FROM users WHERE username = ?`, (err, stmt) => {
|
con.prepare(`SELECT balance FROM users WHERE wallet_address = ?`, (err, bStmt) => {
|
||||||
if (err) return socket.emit('error', { message: 'Database error' });
|
if (err) return socket.emit('error', { message: 'Database error' });
|
||||||
stmt.all(newUsername, (err, rows) => {
|
bStmt.all(walletAddress, (err, rows) => {
|
||||||
stmt.finalize();
|
bStmt.finalize();
|
||||||
if (err) return socket.emit('error', { message: 'Database error' });
|
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) {
|
// Check if username is taken
|
||||||
return socket.emit('error', { message: 'Username already 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
|
if (uRows.length > 0) {
|
||||||
con.prepare(`UPDATE users SET username = ? WHERE wallet_address = ?`, (err, uStmt) => {
|
return socket.emit('error', { message: 'Username already taken' });
|
||||||
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' });
|
|
||||||
|
|
||||||
console.log(`Username updated for ${walletAddress} to ${newUsername}`);
|
// Update username and deduct balance in one go if possible (or chain)
|
||||||
socket.emit('usernameUpdated', { username: newUsername });
|
con.prepare(`UPDATE users SET username = ?, balance = balance - 30 WHERE wallet_address = ?`, (err, uStmt) => {
|
||||||
broadcastUserList();
|
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' });
|
||||||
|
|
||||||
// Also broadcast a system message about the change
|
console.log(`Username updated for ${walletAddress} to ${newUsername}`);
|
||||||
const systemMsg = {
|
socket.emit('usernameUpdated', { username: newUsername });
|
||||||
id: Date.now(),
|
|
||||||
channelId: 'nebula',
|
// Fetch new balance to sync
|
||||||
walletAddress: 'system',
|
con.prepare(`SELECT balance FROM users WHERE wallet_address = ?`, (err, sStmt) => {
|
||||||
username: 'System',
|
if (!err) {
|
||||||
content: `${walletAddress.slice(0, 4)}... changed their name to ${newUsername}`,
|
sStmt.all(walletAddress, (err, rRows) => {
|
||||||
timestamp: new Date().toISOString()
|
sStmt.finalize();
|
||||||
};
|
if (!err && rRows.length > 0) {
|
||||||
io.emit('newMessage', systemMsg);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -139,6 +172,40 @@ io.on('connection', (socket) => {
|
|||||||
con.prepare(`INSERT INTO messages (id, channel_id, wallet_address, content, timestamp, tx_id)
|
con.prepare(`INSERT INTO messages (id, channel_id, wallet_address, content, timestamp, tx_id)
|
||||||
VALUES (nextval('seq_msg_id'), ?, ?, ?, ?, ?) RETURNING id`, (err, stmt) => {
|
VALUES (nextval('seq_msg_id'), ?, ?, ?, ?, ?) RETURNING id`, (err, stmt) => {
|
||||||
if (err) return console.error("Prepare error:", err);
|
if (err) return console.error("Prepare error:", err);
|
||||||
|
|
||||||
|
// Deduct 1 PLEXUS
|
||||||
|
con.prepare(`UPDATE users SET balance = balance - 1 WHERE wallet_address = ? AND balance >= 1`, (err, bStmt) => {
|
||||||
|
if (err) return console.error("Balance update error:", err);
|
||||||
|
bStmt.run(walletAddress, function (err) { // Use function to get this.changes
|
||||||
|
bStmt.finalize();
|
||||||
|
if (err) return console.error("Balance deduct error:", err);
|
||||||
|
|
||||||
|
// If no rows updated, balance was too low (though client should prevent this)
|
||||||
|
// We proceed anyway for now as we don't have easy rollback here without transactions,
|
||||||
|
// but in a real app we'd check first.
|
||||||
|
|
||||||
|
// Fetch new balance to sync client
|
||||||
|
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) {
|
||||||
|
// Emit to specific socket if possible, or broadcast?
|
||||||
|
// We don't have the socket object here easily unless we map wallet -> socket
|
||||||
|
// But we can just rely on client optimistic update for now, or...
|
||||||
|
// Let's try to find the socket
|
||||||
|
for (const [sid, wallet] of connectedSockets.entries()) {
|
||||||
|
if (wallet === walletAddress) {
|
||||||
|
io.to(sid).emit('balanceUpdated', { balance: rows[0].balance });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
stmt.all(channelId, walletAddress, content, timestamp, txId, (err, rows) => {
|
stmt.all(channelId, walletAddress, content, timestamp, txId, (err, rows) => {
|
||||||
stmt.finalize();
|
stmt.finalize();
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -168,27 +235,150 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('getProfile', (walletAddress) => {
|
socket.on('getProfile', (targetAddress) => {
|
||||||
console.log(`Fetching profile for ${walletAddress}`);
|
console.log(`[Profile] Fetching for: ${targetAddress}`);
|
||||||
con.prepare(`SELECT wallet_address, username, bio, banner_color, last_seen FROM users WHERE wallet_address = ?`, (err, stmt) => {
|
con.prepare(`SELECT wallet_address, username, bio, banner_color, balance, last_seen FROM users WHERE wallet_address = ?`, (err, stmt) => {
|
||||||
if (err) return socket.emit('error', { message: 'Database error' });
|
if (err) {
|
||||||
stmt.all(walletAddress, (err, rows) => {
|
console.error('[Profile] DB Prepare error:', err);
|
||||||
|
return socket.emit('error', { message: 'Database error' });
|
||||||
|
}
|
||||||
|
stmt.all(targetAddress, (err, rows) => {
|
||||||
stmt.finalize();
|
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];
|
const user = rows[0];
|
||||||
// Fetch posts
|
// Helper to handle BigInt serialization
|
||||||
con.prepare(`SELECT * FROM posts WHERE wallet_address = ? ORDER BY timestamp DESC LIMIT 50`, (err, pStmt) => {
|
const serializeBigInt = (data) => {
|
||||||
if (err) return socket.emit('profileData', { ...user, posts: [] });
|
return JSON.parse(JSON.stringify(data, (key, value) =>
|
||||||
pStmt.all(walletAddress, (err, posts) => {
|
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();
|
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 }) => {
|
socket.on('updateProfile', ({ walletAddress, bio, bannerColor }) => {
|
||||||
console.log(`Updating profile for ${walletAddress}`);
|
console.log(`Updating profile for ${walletAddress}`);
|
||||||
con.prepare(`UPDATE users SET bio = ?, banner_color = ? WHERE wallet_address = ?`, (err, stmt) => {
|
con.prepare(`UPDATE users SET bio = ?, banner_color = ? WHERE wallet_address = ?`, (err, stmt) => {
|
||||||
@@ -203,22 +393,73 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('createPost', ({ walletAddress, content }) => {
|
socket.on('createPost', ({ walletAddress, content }) => {
|
||||||
if (!content || content.trim() === '') return;
|
console.log(`Creating post for ${walletAddress}`);
|
||||||
console.log(`New post from ${walletAddress}: ${content}`);
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
con.prepare(`INSERT INTO posts (id, wallet_address, content, timestamp) VALUES (nextval('seq_post_id'), ?, ?, ?) RETURNING id`, (err, stmt) => {
|
||||||
con.prepare(`INSERT INTO posts (id, wallet_address, content, timestamp) VALUES (nextval('seq_post_id'), ?, ?, ?)`, (err, stmt) => {
|
if (err) return console.error("Prepare error:", err);
|
||||||
if (err) return socket.emit('error', { message: 'Database error' });
|
stmt.all(walletAddress, content, timestamp, (err, rows) => {
|
||||||
stmt.run(walletAddress, content, timestamp, (err) => {
|
|
||||||
stmt.finalize();
|
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
|
const post = {
|
||||||
// For simplicity, we'll just tell the user it was created
|
id: rows[0].id,
|
||||||
socket.emit('postCreated', { content, timestamp });
|
wallet_address: walletAddress,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
comments: []
|
||||||
|
};
|
||||||
|
|
||||||
// If we want a live feed, we could broadcast to a "profile room"
|
// Broadcast to all (or just profile viewers? for now all)
|
||||||
// For now, the user can just refresh or we emit to them
|
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -339,6 +580,99 @@ app.get('/api/messages/:channelId', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
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, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Server running on port ${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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
server/tests/token.test.js
Normal file
67
server/tests/token.test.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
const { expect } = require('chai');
|
||||||
|
const io = require('socket.io-client');
|
||||||
|
|
||||||
|
const SERVER_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
describe('Token Economy', function () {
|
||||||
|
this.timeout(5000);
|
||||||
|
let socket;
|
||||||
|
const walletAddress = 'TokenTestWallet_' + Date.now();
|
||||||
|
const username = 'TokenUser_' + Date.now();
|
||||||
|
|
||||||
|
before((done) => {
|
||||||
|
// Ensure server is running (assuming it's started externally or we rely on it)
|
||||||
|
// For this test, we assume the server is running on port 3000 as per package.json start script
|
||||||
|
// If not, we might need to start it here, but usually integration tests assume environment
|
||||||
|
|
||||||
|
socket = io(SERVER_URL);
|
||||||
|
socket.on('connect', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
after((done) => {
|
||||||
|
if (socket.connected) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize user with 100 PLEXUS', (done) => {
|
||||||
|
socket.emit('join', { walletAddress, username });
|
||||||
|
|
||||||
|
socket.once('balanceUpdated', (data) => {
|
||||||
|
expect(data.balance).to.equal(100);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduct 1 PLEXUS when sending a message', (done) => {
|
||||||
|
// Wait for join to complete if not already
|
||||||
|
// We can just emit sendMessage, but we need to be sure we are joined?
|
||||||
|
// The previous test joined, so we should be good.
|
||||||
|
|
||||||
|
const channelId = 'nebula';
|
||||||
|
const content = 'Hello World';
|
||||||
|
const txId = 'TX_TEST_' + Date.now();
|
||||||
|
|
||||||
|
// Listen for balance update
|
||||||
|
socket.once('balanceUpdated', (data) => {
|
||||||
|
expect(data.balance).to.equal(99);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit('sendMessage', { channelId, walletAddress, content, txId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduct 30 PLEXUS when changing username', (done) => {
|
||||||
|
const newUsername = 'RichUser_' + Date.now();
|
||||||
|
const txId = 'TX_NAME_' + Date.now();
|
||||||
|
|
||||||
|
socket.once('balanceUpdated', (data) => {
|
||||||
|
// 99 - 30 = 69
|
||||||
|
expect(data.balance).to.equal(69);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit('updateUsername', { walletAddress, newUsername, txId });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user