Compare commits

...

15 Commits

Author SHA1 Message Date
e297ec7245 feat: Update AI model, remove contribution documentation, and enhance DM channel naming and message data in the chat store. 2026-01-18 13:24:49 +01:00
1310d1770a feat: remove tasks 2026-01-18 13:11:51 +01:00
62280265b4 feat: Implement enhanced user profiles with social features including direct messaging, post comments, and reposts, and introduce new routing for Docs and Changelog views. 2026-01-18 13:10:12 +01:00
959b453d69 fix: husky deprecated 2026-01-15 21:38:15 +01:00
64060f6a01 feat: I have implemented the core Web3 economy features for Plexus, aligning it with the "Club 2.0" vision. 2026-01-15 21:38:02 +01:00
712f62f7ae docs: overhaul project documentation and contributing guide (Task #190) 2026-01-13 23:28:16 +01:00
2553d087a0 chore: set up pre-commit hooks and fix linting (Task #181) 2026-01-13 23:27:33 +01:00
ed62ac0641 feat: implement user profiles and social walls (Tasks #171, #173, #174) 2026-01-13 23:24:19 +01:00
477f447b67 chore: remove legacy Token Creator component (Task #172) 2026-01-13 23:22:29 +01:00
bd36b4fda8 feat: implement premium UI, mobile responsiveness, and message status LEDs 2026-01-13 23:21:55 +01:00
7955d88018 feat: update gitignore 2026-01-13 23:15:45 +01:00
588a3500f0 chore: include initial task database in repository 2026-01-13 23:14:28 +01:00
1a59c3435d chore: set up Makefile, linting, and dockerized dev shell 2026-01-13 23:12:55 +01:00
40dbe40b17 feat: implement internal task tracker with Python and DuckDB 2026-01-13 23:12:53 +01:00
41046ad922 docs: implement professional documentation structure and project hygiene 2026-01-13 23:12:50 +01:00
44 changed files with 6229 additions and 530 deletions

1
.gitignore vendored
View File

@@ -36,3 +36,4 @@ server/data/
# OS
.DS_Store
Thumbs.db
*.pyc

18
CHANGELOG.md Normal file
View File

@@ -0,0 +1,18 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- Professional documentation structure in `docs/`.
- Internal task tracker using Python and DuckDB.
- Centralized `Makefile` for project automation.
- Dockerized development shell.
- Unique username enforcement on the server.
- Transaction simulation for messages, reactions, and username changes.
### Fixed
- DuckDB parameter handling issue in the backend.
- Reaction animations and persistence.
- `.gitignore` consolidation.

25
Dockerfile.dev Normal file
View File

@@ -0,0 +1,25 @@
FROM node:20-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
make \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Install DuckDB
RUN curl -L https://github.com/duckdb/duckdb/releases/download/v0.10.0/duckdb_cli-linux-amd64.zip -o duckdb.zip \
&& apt-get update && apt-get install -y unzip \
&& unzip duckdb.zip -d /usr/local/bin \
&& rm duckdb.zip
# Set working directory
WORKDIR /app
# Install Python dependencies
RUN pip3 install duckdb ruff --break-system-packages
# Default command
CMD ["/bin/bash"]

23
Makefile Normal file
View File

@@ -0,0 +1,23 @@
.PHONY: install dev lint shell task-list task-add task-done task-update
# Installation
install:
cd client && npm install
cd server && npm install
pip3 install duckdb ruff --break-system-packages
# Development
dev:
docker compose up --build
# Linting & Testing
lint:
cd client && npm run lint
ruff check tasks/
test:
cd server && npm test
# Docker Shell
shell:
docker compose run --rm dev-shell

51
README.md Normal file
View File

@@ -0,0 +1,51 @@
# 🌌 Plexus
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.
![Desktop View](file:///home/sinan/.gemini/antigravity/brain/d2723a70-2b81-4f4a-b974-6f0dc17d1fae/desktop_view_1768342632058.png)
## 🚀 Key Features
- **💎 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.
- **⚡ Real-time Social**: Instant messaging, reactions, and user profiles with social walls.
- **🚦 Transaction Lifecycle**: Simulated blockchain transaction states (Pending, Validated, Failed).
## 🛠 Tech Stack
- **Frontend**: Vue 3, Pinia, Tailwind CSS, Lucide Icons.
- **Backend**: Node.js, Express, Socket.io.
- **Database**: DuckDB (Fast, analytical, and serverless-friendly).
- **Automation**: Makefile, Husky, Lint-staged, Ruff (Python), ESLint (JS).
## 📖 Documentation
Explore our detailed documentation in the `docs/` directory:
- [O Vision](./docs/vision.md) - Platform vision.
- [🏗 Architecture](./docs/architecture.md) - High-level system design.
- [📂 Structure](./docs/structure.md) - Directory and file organization.
- [⚙️ Functions & API](./docs/functions.md) - Socket events and backend logic.
- [📊 Data Model](./docs/data-model.md) - Database schema and migrations.
- [📈 Scalability](./docs/scalability.md) - Future roadmap and scaling strategies.
- [📝 Task Tracker](./docs/tasks.md) - How to use the internal task management tool.
## 🚦 Quick Start
### Prerequisites
- Node.js (v18+)
- Python 3.10+
- Docker (optional, for dev shell)
### Installation
```bash
make install
```
### Development
```bash
# Start the dev environment (Docker)
make dev
```

21
client/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,21 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: [
'vue',
],
rules: {
'vue/multi-word-component-names': 'off',
},
};

View File

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

2695
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"lint": "eslint . --ext .js,.vue --fix"
},
"dependencies": {
"@solana/web3.js": "^1.98.4",
@@ -16,13 +17,19 @@
"pinia": "^3.0.4",
"socket.io-client": "^4.8.3",
"tweetnacl": "^1.0.3",
"vue": "^3.5.24"
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.21.1",
"jsdom": "^27.4.0",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.17",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.17"
}
}

View File

@@ -1,13 +1,11 @@
<script setup>
import { ref } from 'vue';
import { useChatStore } from './stores/chat';
import WalletConnect from './components/WalletConnect.vue';
import ChatLayout from './components/ChatLayout.vue';
const chatStore = useChatStore();
const videoRef = ref(null);
const handleMuteToggle = (isMuted) => {
const handleMuteToggle = () => {
if (videoRef.value) {
// Note: YouTube iframe API would be needed for true control,
// but for a simple background video loop, we can't easily unmute a background iframe without user interaction policies.
@@ -27,15 +25,14 @@ const handleMuteToggle = (isMuted) => {
frameborder="0"
allow="autoplay; encrypted-media"
allowfullscreen
></iframe>
/>
<!-- Overlay gradient -->
<div class="absolute inset-0 bg-crypto-dark/60 backdrop-blur-[2px]"></div>
<div class="absolute inset-0 bg-crypto-dark/60 backdrop-blur-[2px]" />
</div>
<!-- Content -->
<div class="relative z-10 h-full">
<WalletConnect v-if="!chatStore.isConnected" />
<ChatLayout v-else @toggleMute="handleMuteToggle" />
<router-view @toggle-mute="handleMuteToggle" />
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -2,17 +2,19 @@
import { ref, onUpdated, nextTick } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { Send, Hash, Smile } from 'lucide-vue-next';
import { Send, Hash, Smile, ExternalLink, Copy, Check } from 'lucide-vue-next';
const chatStore = useChatStore();
const { currentMessages, currentChannel, walletAddress } = storeToRefs(chatStore);
const newMessage = ref('');
const messagesContainer = ref(null);
const showEmojiPicker = ref(null); // messageId
const showEmojiPicker = ref(null);
const copiedTxId = ref(null);
const EMOJIS = ['👍', '❤️', '🔥', '😂', '😮', '😢'];
const EMOJIS = ['👍', '❤️', '🔥', '😂', '😮', '😢', '🚀', '💎'];
const toggleReaction = (messageId, emoji) => {
console.log('Toggling reaction:', messageId, emoji);
chatStore.toggleReaction(messageId, emoji);
showEmojiPicker.value = null;
};
@@ -50,68 +52,128 @@ const send = () => {
const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const copyTxId = (txId) => {
navigator.clipboard.writeText(txId);
copiedTxId.value = txId;
setTimeout(() => { copiedTxId.value = null; }, 2000);
};
const emit = defineEmits(['view-profile']);
</script>
<template>
<div class="flex-1 flex flex-col h-full bg-black/40 backdrop-blur-sm relative z-10">
<!-- Header -->
<div class="h-12 border-b border-white/5 flex items-center px-4 shadow-sm bg-crypto-panel/50">
<div class="text-lg font-bold text-white"># {{ currentChannel }}</div>
<div class="flex-1 flex flex-col h-full bg-discord-dark relative z-10">
<!-- Header (Desktop only) -->
<div class="hidden md:flex h-12 border-b border-black/20 items-center px-4 shadow-sm bg-discord-dark/95 backdrop-blur-sm">
<div class="text-sm font-bold text-white flex items-center gap-2">
<Hash size="18" class="text-gray-400" />
{{ currentChannel }}
</div>
</div>
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-1 scroll-smooth">
<div
ref="messagesContainer"
class="flex-1 overflow-y-auto p-4 space-y-1 scroll-smooth custom-scrollbar"
>
<!-- Beginning of conversation marker -->
<div class="py-12 px-4 border-b border-white/5 mb-8">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center text-white mb-4 shadow-xl shadow-violet-600/20">
<Hash size="32" />
</div>
<h2 class="text-2xl font-bold text-white mb-1">Welcome to #{{ currentChannel }}!</h2>
<p class="text-gray-400 text-sm max-w-md">This is the very beginning of the <span class="text-violet-400 font-semibold">#{{ currentChannel }}</span> channel. Use this space to connect, share, and grow with the community.</p>
<h2 class="text-3xl font-bold text-white mb-2">
Welcome to #{{ currentChannel }}!
</h2>
<p class="text-gray-400 text-base max-w-md leading-relaxed">
This is the very beginning of the <span class="text-violet-400 font-semibold">#{{ currentChannel }}</span> channel. Use this space to connect, share, and grow with the community.
</p>
</div>
<div v-for="(msg, index) in currentMessages" :key="msg.id"
class="group flex gap-4 px-4 py-1 hover:bg-white/[0.02] transition-colors relative"
<div
v-for="(msg, index) in currentMessages"
:key="msg.id || msg.tempId"
: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 - always show for every message -->
<div class="w-10 flex-shrink-0">
<div v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shadow-lg border border-white/10 mt-1"
:class="msg.walletAddress === walletAddress ? 'bg-gradient-to-br from-violet-500 to-fuchsia-600' : 'bg-crypto-panel'"
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shadow-lg border border-white/10 mt-1 cursor-pointer hover:opacity-80 transition-opacity"
:class="msg.walletAddress === walletAddress ? 'bg-gradient-to-br from-violet-500 to-fuchsia-600' : 'bg-discord-sidebar'"
@click="emit('view-profile', msg.walletAddress)"
>
{{ msg.username.substring(0, 2).toUpperCase() }}
</div>
<div v-else class="w-10 text-[10px] text-crypto-muted opacity-0 group-hover:opacity-100 text-right pr-2 pt-1.5 transition-opacity">
{{ formatTime(msg.timestamp) }}
{{ msg.username?.substring(0, 2).toUpperCase() || '??' }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress" class="flex items-baseline gap-2 mb-0.5">
<span :class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']">
{{ msg.username }}
</span>
<span v-if="msg.txId" class="text-[9px] text-crypto-muted font-mono bg-white/5 px-1.5 py-0.5 rounded border border-white/5">
{{ msg.txId.slice(0, 8) }}
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span
:class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']"
@click="emit('view-profile', msg.walletAddress)"
>
{{ msg.username || 'Anonymous' }}
</span>
<!-- Status LED next to username -->
<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'
}"
/>
<span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</span>
</div>
<div class="text-gray-100 text-sm leading-relaxed break-words">
<!-- Message Content & Status -->
<div class="flex items-start gap-2">
<div :class="['text-sm leading-relaxed break-words flex-1', msg.status === 'failed' ? 'text-red-400 line-through' : 'text-gray-100']">
{{ msg.content }}
</div>
<!-- Transaction ID (show on hover) -->
<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>
</div>
<!-- Failed message notice -->
<div v-if="msg.status === 'failed'" class="text-[10px] text-red-400 mt-1 flex items-center gap-1">
Transaction failed - message not saved
</div>
<!-- Reactions Display -->
<div v-if="msg.reactions && msg.reactions.length > 0" class="flex flex-wrap gap-1 mt-1.5">
<div
v-if="msg.reactions && msg.reactions.length > 0"
class="flex flex-wrap gap-1 mt-2"
>
<button
v-for="emoji in getUniqueEmojis(msg.reactions)"
:key="emoji"
@click="toggleReaction(msg.id, emoji)"
:class="['flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-xs border transition-all animate-pop-in',
:class="['flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-all',
hasUserReacted(msg.reactions, emoji)
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10']"
? 'bg-violet-500/20 border-violet-500/50 text-violet-300 shadow-sm shadow-violet-500/20'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:border-white/20']"
@click="toggleReaction(msg.id, emoji)"
>
<span>{{ emoji }}</span>
<span class="font-bold">{{ getReactionCount(msg.reactions, emoji) }}</span>
@@ -120,22 +182,28 @@ const formatTime = (isoString) => {
</div>
<!-- Hover Actions -->
<div class="absolute right-4 -top-4 opacity-0 group-hover:opacity-100 transition-opacity z-20 flex gap-1 bg-crypto-panel border border-white/10 rounded-lg p-1 shadow-xl">
<div
v-if="msg.status !== 'failed' && msg.id"
class="absolute right-4 -top-4 opacity-0 group-hover:opacity-100 transition-all z-20 flex gap-1 bg-discord-sidebar border border-white/10 rounded-xl p-1.5 shadow-2xl"
>
<button
@click="showEmojiPicker = showEmojiPicker === msg.id ? null : msg.id"
class="p-1.5 hover:bg-white/10 rounded text-gray-400 hover:text-white transition-all"
class="p-2 hover:bg-white/10 rounded-lg text-gray-400 hover:text-white transition-all"
title="Add Reaction"
@click="showEmojiPicker = showEmojiPicker === msg.id ? null : msg.id"
>
<Smile size="16" />
</button>
<!-- Emoji Picker Popover -->
<div v-if="showEmojiPicker === msg.id" class="absolute right-0 bottom-full mb-2 bg-crypto-panel border border-white/10 rounded-xl p-2 shadow-2xl flex gap-1 z-30 animate-fade-in-up">
<div
v-if="showEmojiPicker === msg.id"
class="absolute right-0 bottom-full mb-2 bg-discord-sidebar border border-white/10 rounded-xl p-2 shadow-2xl flex gap-1 z-30 animate-pop-in"
>
<button
v-for="emoji in EMOJIS"
:key="emoji"
class="hover:scale-125 transition-transform p-1.5 text-lg hover:bg-white/10 rounded-lg"
@click="toggleReaction(msg.id, emoji)"
class="hover:scale-125 transition-transform p-1 text-lg"
>
{{ emoji }}
</button>
@@ -145,22 +213,33 @@ const formatTime = (isoString) => {
</div>
<!-- Input -->
<div class="p-4 bg-crypto-panel/80 border-t border-white/5">
<div class="relative">
<div class="p-4 bg-discord-dark border-t border-white/5">
<div class="relative bg-discord-sidebar/50 rounded-xl border border-white/5 p-1 transition-all focus-within:border-violet-500/30 focus-within:bg-discord-sidebar/80 focus-within:shadow-lg focus-within:shadow-violet-500/5">
<input
v-model="newMessage"
@keyup.enter="send"
type="text"
:placeholder="`Message #${currentChannel}`"
class="w-full bg-crypto-dark/50 text-white placeholder-gray-500 rounded-lg py-3 pl-4 pr-12 focus:outline-none focus:ring-2 focus:ring-crypto-accent/50 border border-white/5 transition-all"
/>
class="w-full bg-transparent text-white placeholder-gray-500 py-3 pl-4 pr-12 focus:outline-none"
@keyup.enter="send"
>
<button
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"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-white hover:bg-white/10 rounded-md transition-colors"
>
<Send size="20" />
</button>
</div>
<div class="mt-3 flex items-center gap-6 px-1">
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-orange w-2 h-2 animate-pulse" /> Pending
</div>
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-green w-2 h-2" /> Validated
</div>
<div class="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-red w-2 h-2" /> Failed (not saved)
</div>
</div>
</div>
</div>
</template>
@@ -174,13 +253,4 @@ const formatTime = (isoString) => {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.animate-fade-in-up {
animation: fade-in-up 0.2s ease-out;
}
@keyframes fade-in-up {
0% { transform: translateY(10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
</style>

View File

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

View File

@@ -1,142 +0,0 @@
<script setup>
import { ref } from 'vue';
import { Coins, ArrowLeft, Rocket, Shield, Zap } from 'lucide-vue-next';
const emit = defineEmits(['back']);
const tokenName = ref('');
const tokenSymbol = ref('');
const decimals = ref(9);
const totalSupply = ref(1000000000);
const isCreating = ref(false);
const success = ref(false);
const createToken = async () => {
if (!tokenName.value || !tokenSymbol.value) return;
isCreating.value = true;
// Simulate blockchain interaction
await new Promise(resolve => setTimeout(resolve, 2000));
isCreating.value = false;
success.value = true;
};
</script>
<template>
<div class="flex-1 flex flex-col h-full bg-black/40 backdrop-blur-sm relative z-10 overflow-y-auto">
<!-- Header -->
<div class="h-12 border-b border-white/5 flex items-center px-4 shadow-sm bg-crypto-panel/50 sticky top-0 z-20">
<button @click="emit('back')" class="mr-4 p-1.5 hover:bg-white/10 rounded-lg text-gray-400 hover:text-white transition-all">
<ArrowLeft size="18" />
</button>
<div class="text-lg font-bold text-white flex items-center gap-2">
<Coins size="20" class="text-violet-400" />
Token Creator
</div>
</div>
<div class="p-8 max-w-2xl mx-auto w-full">
<div v-if="!success" class="space-y-8 animate-fade-in">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-white mb-2">Create your PLEXUS Token</h2>
<p class="text-crypto-muted">Launch your own SPL token on Solana in seconds.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-bold text-crypto-muted uppercase tracking-wider ml-1">Token Name</label>
<input
v-model="tokenName"
type="text"
placeholder="e.g. Plexus Gold"
class="w-full bg-crypto-dark/50 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"
/>
</div>
<div class="space-y-2">
<label class="text-xs font-bold text-crypto-muted uppercase tracking-wider ml-1">Symbol</label>
<input
v-model="tokenSymbol"
type="text"
placeholder="e.g. PXG"
class="w-full bg-crypto-dark/50 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"
/>
</div>
<div class="space-y-2">
<label class="text-xs font-bold text-crypto-muted uppercase tracking-wider ml-1">Decimals</label>
<input
v-model="decimals"
type="number"
class="w-full bg-crypto-dark/50 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"
/>
</div>
<div class="space-y-2">
<label class="text-xs font-bold text-crypto-muted uppercase tracking-wider ml-1">Total Supply</label>
<input
v-model="totalSupply"
type="number"
class="w-full bg-crypto-dark/50 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"
/>
</div>
</div>
<div class="bg-violet-500/10 border border-violet-500/20 rounded-2xl p-6 space-y-4">
<h3 class="font-bold text-white flex items-center gap-2">
<Shield size="18" class="text-violet-400" />
Security Features
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div class="flex items-center gap-3 text-gray-300">
<Zap size="14" class="text-yellow-400" />
Revoke Mint Authority
</div>
<div class="flex items-center gap-3 text-gray-300">
<Zap size="14" class="text-yellow-400" />
Revoke Freeze Authority
</div>
</div>
</div>
<button
@click="createToken"
:disabled="isCreating || !tokenName || !tokenSymbol"
class="w-full py-4 bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white rounded-2xl font-bold shadow-xl shadow-violet-600/20 transition-all transform hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
>
<Rocket v-if="!isCreating" size="20" />
<span v-if="isCreating" class="animate-spin rounded-full h-5 w-5 border-2 border-white/30 border-t-white"></span>
{{ isCreating ? 'Launching on Solana...' : 'Create Token' }}
</button>
</div>
<div v-else class="text-center py-12 animate-bounce-in">
<div class="w-24 h-24 bg-green-500/20 border border-green-500/50 rounded-full flex items-center justify-center mx-auto mb-6">
<Rocket size="48" class="text-green-400" />
</div>
<h2 class="text-3xl font-bold text-white mb-2">Token Launched!</h2>
<p class="text-crypto-muted mb-8">Your token <span class="text-white font-bold">${{ tokenSymbol }}</span> has been successfully created on the Solana blockchain.</p>
<button @click="success = false; tokenName = ''; tokenSymbol = ''" class="px-8 py-3 bg-white/10 hover:bg-white/20 text-white rounded-xl font-medium transition-all">
Create Another
</button>
</div>
</div>
</div>
</template>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-bounce-in {
animation: bounceIn 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes bounceIn {
0% { opacity: 0; transform: scale(0.3); }
50% { opacity: 1; transform: scale(1.05); }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
</style>

View File

@@ -1,9 +1,17 @@
<script setup>
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { MessageSquare } from 'lucide-vue-next';
const chatStore = useChatStore();
const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
const { onlineUsers, offlineUsers, walletAddress } = storeToRefs(chatStore);
const emit = defineEmits(['view-profile']);
const startDM = (e, targetWallet) => {
e.stopPropagation(); // Prevent opening profile
chatStore.startDM(targetWallet);
};
</script>
<template>
@@ -11,40 +19,74 @@ const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
<div class="flex-1 overflow-y-auto p-3 space-y-6">
<!-- Online Users -->
<div v-if="onlineUsers.length > 0">
<h3 class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">Online {{ onlineUsers.length }}</h3>
<h3 class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
Online {{ onlineUsers.length }}
</h3>
<div class="space-y-0.5">
<div v-for="user in onlineUsers" :key="user.wallet_address" class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all">
<div
v-for="user in onlineUsers"
:key="user.wallet_address"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all relative"
@click="emit('view-profile', user.wallet_address)"
>
<div class="relative">
<div class="w-8 h-8 rounded-full bg-violet-600 flex items-center justify-center text-xs font-bold text-white shadow-sm">
{{ user.username.substring(0, 2).toUpperCase() }}
</div>
<div class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 border-2 border-[#2b2d31] rounded-full"></div>
<div class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 border-2 border-[#2b2d31] rounded-full" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-300 truncate group-hover:text-white transition-colors">
{{ user.username }}
</div>
</div>
<!-- Message Button (Hover) -->
<button
v-if="user.wallet_address !== walletAddress"
class="absolute right-2 p-1.5 bg-discord-black rounded-full text-gray-400 hover:text-white hover:bg-violet-600 opacity-0 group-hover:opacity-100 transition-all shadow-lg"
title="Message"
@click="(e) => startDM(e, user.wallet_address)"
>
<MessageSquare size="12" />
</button>
</div>
</div>
</div>
<!-- Offline Users -->
<div v-if="offlineUsers.length > 0">
<h3 class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">Offline {{ offlineUsers.length }}</h3>
<h3 class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
Offline {{ offlineUsers.length }}
</h3>
<div class="space-y-0.5">
<div v-for="user in offlineUsers" :key="user.wallet_address" class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all opacity-60 hover:opacity-100">
<div
v-for="user in offlineUsers"
:key="user.wallet_address"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-[#35373c] cursor-pointer group transition-all opacity-60 hover:opacity-100 relative"
@click="emit('view-profile', user.wallet_address)"
>
<div class="relative">
<div class="w-8 h-8 rounded-full bg-[#3f4147] flex items-center justify-center text-xs font-bold text-gray-500">
{{ user.username.substring(0, 2).toUpperCase() }}
</div>
<div class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-gray-600 border-2 border-[#2b2d31] rounded-full"></div>
<div class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-gray-600 border-2 border-[#2b2d31] rounded-full" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-500 truncate group-hover:text-gray-400">
{{ user.username }}
</div>
</div>
<!-- Message Button (Hover) -->
<button
v-if="user.wallet_address !== walletAddress"
class="absolute right-2 p-1.5 bg-discord-black rounded-full text-gray-400 hover:text-white hover:bg-violet-600 opacity-0 group-hover:opacity-100 transition-all shadow-lg"
title="Message"
@click="(e) => startDM(e, user.wallet_address)"
>
<MessageSquare size="12" />
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,443 @@
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { MessageSquare, Calendar, Edit3, Send, X, Wallet, Coins, Copy, Check, Repeat2 } from 'lucide-vue-next';
const props = defineProps({
address: {
type: String,
required: true
}
});
const chatStore = useChatStore();
const { profileUser, profilePosts, profileReposts, isProfileLoading, walletAddress, balance } = storeToRefs(chatStore);
const isEditing = ref(false);
const editBio = ref('');
const editBannerColor = ref('');
const editUsername = ref('');
const newPostContent = ref('');
const copiedAddress = ref(false);
// Comments state
const activeCommentPostId = ref(null);
const newCommentContent = ref('');
const isOwnProfile = computed(() => profileUser.value?.wallet_address === walletAddress.value);
const loadProfile = () => {
chatStore.getProfile(props.address);
};
onMounted(loadProfile);
watch(() => props.address, loadProfile);
const startEditing = () => {
editBio.value = profileUser.value.bio || '';
editBannerColor.value = profileUser.value.banner_color || '#6366f1';
editUsername.value = profileUser.value.username || '';
isEditing.value = true;
};
const saveProfile = () => {
if (editUsername.value && editUsername.value !== profileUser.value.username) {
chatStore.updateUsername(editUsername.value);
}
chatStore.updateProfile(editBio.value, editBannerColor.value);
isEditing.value = false;
};
const submitPost = () => {
if (!newPostContent.value.trim()) return;
chatStore.createPost(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 date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const copyAddress = () => {
if (profileUser.value?.wallet_address) {
navigator.clipboard.writeText(profileUser.value.wallet_address);
copiedAddress.value = true;
setTimeout(() => { copiedAddress.value = false; }, 2000);
}
};
const shortenAddress = (addr) => {
if (!addr) return '';
return addr.slice(0, 6) + '...' + addr.slice(-4);
};
</script>
<template>
<div class="flex-1 flex flex-col h-full bg-discord-dark overflow-y-auto custom-scrollbar">
<div
v-if="isProfileLoading"
class="flex-1 flex items-center justify-center"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-violet-500" />
</div>
<div
v-else-if="profileUser"
class="flex flex-col"
>
<!-- Banner -->
<div
class="h-48 w-full relative transition-colors duration-500"
:style="{ backgroundColor: profileUser.banner_color }"
>
<div class="absolute inset-0 bg-gradient-to-t from-discord-dark/80 to-transparent" />
<div class="absolute -bottom-16 left-8">
<div class="w-32 h-32 rounded-full border-8 border-discord-dark bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center text-white text-4xl font-bold shadow-2xl shadow-violet-600/30">
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
</div>
</div>
</div>
<!-- Profile Info -->
<div class="mt-20 px-8 pb-8 border-b border-white/5">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white">
{{ profileUser.username }}
</h1>
<button
class="flex items-center gap-2 text-gray-400 font-mono text-sm mt-1 hover:text-violet-400 transition-colors group"
@click="copyAddress"
>
<Wallet size="14" />
{{ shortenAddress(profileUser.wallet_address) }}
<Check v-if="copiedAddress" size="14" class="text-green-400" />
<Copy v-else size="14" class="opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</div>
<div class="flex gap-2">
<button
v-if="!isOwnProfile"
class="px-4 py-2 bg-violet-600 hover:bg-violet-500 rounded-full text-white text-sm font-medium transition-all flex items-center gap-2 shadow-lg shadow-violet-600/20"
@click="startDM"
>
<MessageSquare size="16" /> Message
</button>
<button
v-if="isOwnProfile"
class="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-white text-sm font-medium transition-all flex items-center gap-2"
@click="startEditing"
>
<Edit3 size="16" /> Edit Profile
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="mt-6 grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- Balance Card -->
<div class="bg-gradient-to-br from-yellow-500/10 to-orange-500/10 border border-yellow-500/20 rounded-xl p-4">
<div class="flex items-center gap-2 text-yellow-400 text-sm font-medium mb-1">
<Coins size="16" /> Balance
</div>
<div class="text-2xl font-bold text-white">
{{ isOwnProfile ? balance : '---' }}
<span class="text-sm text-yellow-400">$PLEXUS</span>
</div>
</div>
<!-- Posts Card -->
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<div class="flex items-center gap-2 text-violet-400 text-sm font-medium mb-1">
<MessageSquare size="16" /> Posts
</div>
<div class="text-2xl font-bold text-white">
{{ profilePosts?.length || 0 }}
</div>
</div>
<!-- Reposts Card -->
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<div class="flex items-center gap-2 text-emerald-400 text-sm font-medium mb-1">
<Repeat2 size="16" /> Reposts
</div>
<div class="text-2xl font-bold text-white">
{{ profilePosts?.reduce((acc, p) => acc + (p.repost_count || 0), 0) }}
</div>
</div>
<!-- Joined Card -->
<div class="bg-white/5 border border-white/10 rounded-xl p-4">
<div class="flex items-center gap-2 text-gray-400 text-sm font-medium mb-1">
<Calendar size="16" /> Joined
</div>
<div class="text-lg font-bold text-white">
{{ new Date(profileUser.last_seen).toLocaleDateString() }}
</div>
</div>
</div>
<!-- Bio -->
<div class="mt-6 text-gray-200 text-lg leading-relaxed max-w-2xl">
{{ profileUser.bio || 'No bio yet...' }}
</div>
</div>
<!-- Wall / Posts -->
<div class="p-8 max-w-3xl">
<h2 class="text-xl font-bold text-white mb-6 flex items-center gap-2">
<MessageSquare
size="20"
class="text-violet-400"
/> Wall
</h2>
<!-- New Post Input (only for owner) -->
<div
v-if="isOwnProfile"
class="mb-8 bg-discord-sidebar/30 rounded-2xl p-4 border border-white/5 focus-within:border-violet-500/30 transition-all"
>
<textarea
v-model="newPostContent"
placeholder="What's on your mind?"
class="w-full bg-transparent text-white placeholder-gray-500 resize-none focus:outline-none min-h-[100px]"
/>
<div class="flex justify-end mt-2 pt-2 border-t border-white/5">
<button
class="px-6 py-2 bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white rounded-full font-bold transition-all flex items-center gap-2 shadow-lg shadow-violet-600/20"
@click="submitPost"
>
Post <Send size="16" />
</button>
</div>
</div>
<!-- Posts List -->
<div class="space-y-6">
<div
v-if="profilePosts.length === 0"
class="text-center py-12 text-gray-500 italic"
>
No posts yet.
</div>
<div
v-for="post in profilePosts"
:key="post.id"
class="bg-discord-sidebar/20 rounded-2xl p-6 border border-white/5 hover:border-violet-500/20 transition-all group"
>
<div class="flex justify-between items-start mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-violet-600/30 to-fuchsia-600/30 flex items-center justify-center text-violet-400 font-bold">
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
</div>
<div>
<div class="font-bold text-white">
{{ profileUser.username }}
</div>
<div class="text-xs text-gray-500">
{{ formatTime(post.timestamp) }}
</div>
</div>
</div>
<button
:class="[
'transition-opacity p-2 rounded-lg flex items-center gap-1',
hasUserReposted(post)
? 'text-emerald-400 bg-emerald-500/10'
: 'opacity-0 group-hover:opacity-100 text-gray-400 hover:bg-white/5 hover:text-emerald-400'
]"
:title="hasUserReposted(post) ? 'Undo Repost' : 'Repost'"
@click="repostPost(post.id)"
>
<Repeat2 size="16" />
<span v-if="post.repost_count > 0" class="text-xs">{{ post.repost_count }}</span>
</button>
</div>
<div class="text-gray-200 leading-relaxed mb-4">
{{ post.content }}
</div>
<!-- Comments Section -->
<div class="border-t border-white/5 pt-4">
<button
class="text-xs font-bold text-gray-500 hover:text-violet-400 transition-colors flex items-center gap-2 mb-3"
@click="toggleComments(post.id)"
>
<MessageSquare size="14" />
{{ post.comment_count || post.comments?.length || 0 }} Comments
</button>
<div v-if="activeCommentPostId === post.id" class="space-y-3 animate-fade-in">
<!-- Existing Comments -->
<div v-if="post.comments && post.comments.length > 0" class="space-y-3 pl-4 border-l-2 border-white/5">
<div v-for="comment in post.comments" :key="comment.id" class="text-sm">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-white text-xs">{{ comment.username }}</span>
<span class="text-[10px] text-gray-500">{{ formatTime(comment.timestamp) }}</span>
</div>
<div class="text-gray-300">{{ comment.content }}</div>
</div>
</div>
<!-- Add Comment -->
<div class="flex gap-2 mt-3">
<input
v-model="newCommentContent"
type="text"
placeholder="Write a comment..."
class="flex-1 bg-discord-black/50 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50 transition-all"
@keyup.enter="submitComment(post.id)"
>
<button
class="p-2 bg-violet-600/20 hover:bg-violet-600/40 text-violet-400 rounded-lg transition-colors"
@click="submitComment(post.id)"
>
<Send size="14" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reposts Section -->
<div v-if="profileReposts.length > 0" class="p-8 max-w-3xl border-t border-white/5">
<h2 class="text-xl font-bold text-white mb-6 flex items-center gap-2">
<Repeat2
size="20"
class="text-emerald-400"
/> Reposts
</h2>
<div class="space-y-6">
<div
v-for="repost in profileReposts"
:key="repost.id"
class="bg-discord-sidebar/20 rounded-2xl p-6 border border-white/5 hover:border-emerald-500/20 transition-all"
>
<div class="flex items-center gap-2 text-xs text-emerald-400 mb-3">
<Repeat2 size="14" />
<span>{{ profileUser.username }} reposted</span>
</div>
<div class="flex items-center gap-3 mb-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-gray-600/30 to-gray-700/30 flex items-center justify-center text-gray-400 font-bold text-sm">
{{ repost.original_username?.substring(0, 2).toUpperCase() }}
</div>
<div>
<div class="font-bold text-white text-sm">
{{ repost.original_username }}
</div>
<div class="text-[10px] text-gray-500">
{{ formatTime(repost.timestamp) }}
</div>
</div>
</div>
<div class="text-gray-200 leading-relaxed text-sm">
{{ repost.content }}
</div>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div
v-if="isEditing"
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-lg shadow-2xl animate-pop-in">
<div class="p-6 border-b border-white/5 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">
Edit Profile
</h2>
<button
class="text-gray-400 hover:text-white transition-colors"
@click="isEditing = false"
>
<X size="24" />
</button>
</div>
<div class="p-6 space-y-6">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Username (30 $PLEXUS)</label>
<input
v-model="editUsername"
type="text"
class="w-full bg-discord-black border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all font-bold mb-4"
placeholder="Username"
/>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bio</label>
<textarea
v-model="editBio"
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 min-h-[120px]"
placeholder="Tell us about yourself..."
/>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Banner Color</label>
<div class="flex gap-3">
<input
v-model="editBannerColor"
type="color"
class="w-12 h-12 rounded-lg bg-transparent border-none cursor-pointer"
>
<input
v-model="editBannerColor"
type="text"
class="flex-1 bg-discord-black border border-white/10 rounded-xl px-4 py-3 text-white font-mono"
>
</div>
</div>
</div>
<div class="p-6 border-t border-white/5 flex gap-3">
<button
class="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-white font-medium hover:bg-white/5 transition-all"
@click="isEditing = false"
>
Cancel
</button>
<button
class="flex-1 px-4 py-2.5 rounded-xl bg-violet-600 text-white font-medium hover:bg-violet-500 shadow-lg shadow-violet-600/20 transition-all"
@click="saveProfile"
>
Save Changes
</button>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia';
import { io } from 'socket.io-client';
import { ref, computed, onMounted } from 'vue';
import Cookies from 'js-cookie';
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
export const useChatStore = defineStore('chat', () => {
const socket = ref(null);
@@ -9,12 +9,20 @@ export const useChatStore = defineStore('chat', () => {
const walletAddress = ref(null);
const username = 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 messages = ref({}); // { channelId: [messages] }
const users = ref([]);
const channels = ref([]);
// Profile state
const profileUser = ref(null);
const profilePosts = ref([]);
const profileReposts = ref([]);
const isProfileLoading = ref(false);
const profilePicture = ref(localStorage.getItem('plexus_nft_pic') || null);
const onlineUsers = computed(() => users.value.filter(u => u.online));
const offlineUsers = computed(() => users.value.filter(u => !u.online));
@@ -25,7 +33,7 @@ export const useChatStore = defineStore('chat', () => {
username.value = name;
if (sig) {
signature.value = sig;
Cookies.set('plexus_auth', JSON.stringify({ wallet, name, sig }), { expires: 7 });
saveSession(wallet, name, sig);
}
// Connect to same origin (proxied by Vite in dev, Nginx in prod)
@@ -45,8 +53,24 @@ export const useChatStore = defineStore('chat', () => {
if (!messages.value[message.channelId]) {
messages.value[message.channelId] = [];
}
// Use spread to trigger reactivity if needed, though .push should work in Vue 3
messages.value[message.channelId] = [...messages.value[message.channelId], message];
// Check if this message matches a local pending message (by txId)
const pendingIdx = messages.value[message.channelId].findIndex(
m => m.status === 'pending' && m.txId === message.txId
);
if (pendingIdx !== -1) {
// Update the pending message with server data and mark as validated
messages.value[message.channelId][pendingIdx] = { ...message, status: 'validated' };
} else {
// 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' }];
}
}
});
socket.value.on('userList', (userList) => {
@@ -54,51 +78,152 @@ export const useChatStore = defineStore('chat', () => {
});
socket.value.on('updateReactions', ({ messageId, reactions }) => {
console.log('Received updateReactions for message:', messageId, 'reactions:', reactions);
for (const channelId in messages.value) {
const msg = messages.value[channelId].find(m => m.id === messageId);
const msg = messages.value[channelId].find(m => String(m.id) === String(messageId));
if (msg) {
msg.reactions = reactions;
// Trigger reactivity by replacing the array
messages.value[channelId] = [...messages.value[channelId]];
break;
}
}
});
socket.value.on('profileData', (data) => {
console.log('Profile data received:', data);
if (!data) return;
profileUser.value = {
wallet_address: data.wallet_address || '',
username: data.username || 'Anonymous',
bio: data.bio || '',
banner_color: data.banner_color || '#6366f1',
last_seen: data.last_seen || new Date().toISOString()
};
profilePosts.value = Array.isArray(data.posts) ? data.posts : [];
profileReposts.value = Array.isArray(data.reposts) ? data.reposts : [];
isProfileLoading.value = false;
});
socket.value.on('profileUpdated', (data) => {
console.log('Profile updated event:', data);
if (!data) return;
if (profileUser.value && profileUser.value.wallet_address === data.wallet_address) {
profileUser.value = { ...profileUser.value, ...data };
}
// Also update main user state if it's the current user
if (data.username && data.wallet_address === walletAddress.value) {
username.value = data.username;
localStorage.setItem('plexus_username', data.username);
}
});
socket.value.on('postCreated', (post) => {
profilePosts.value = [post, ...profilePosts.value];
});
socket.value.on('commentCreated', (comment) => {
const post = profilePosts.value.find(p => p.id === comment.post_id);
if (post) {
if (!post.comments) post.comments = [];
post.comments.push(comment);
}
});
socket.value.on('commentsLoaded', ({ postId, comments }) => {
const post = profilePosts.value.find(p => p.id === postId);
if (post) {
post.comments = comments;
// Update count just in case
post.comment_count = comments.length;
}
});
socket.value.on('repostToggled', ({ postId, walletAddress: repostWallet, action }) => {
const post = profilePosts.value.find(p => p.id === postId);
if (post) {
if (!post.reposted_by) post.reposted_by = [];
if (!post.repost_count) post.repost_count = 0;
if (action === 'added') {
if (!post.reposted_by.includes(repostWallet)) {
post.reposted_by.push(repostWallet);
post.repost_count++;
}
} else if (action === 'removed') {
const idx = post.reposted_by.indexOf(repostWallet);
if (idx > -1) {
post.reposted_by.splice(idx, 1);
post.repost_count = Math.max(0, post.repost_count - 1);
}
}
}
});
socket.value.on('usernameUpdated', ({ username: newName }) => {
username.value = newName;
saveSession(walletAddress.value, newName, signature.value);
});
socket.value.on('balanceUpdated', ({ balance: newBalance }) => {
balance.value = newBalance;
localStorage.setItem('plexus_balance', newBalance.toString());
});
socket.value.on('error', (err) => {
console.error('Socket error:', err);
// Handle failed messages if we can identify them
// For now, just alert
alert(err.message || 'An error occurred');
});
fetchChannels();
fetchMessages(currentChannel.value);
}
function toggleReaction(messageId, emoji) {
if (!socket.value) return;
// Simulate a blockchain transaction for reaction
console.log('Simulating 1 $PLEXUS transaction for reaction...');
socket.value.emit('toggleReaction', {
messageId,
walletAddress: walletAddress.value,
emoji
});
}
onMounted(() => {
const savedAuth = Cookies.get('plexus_auth');
if (savedAuth) {
try {
const { wallet, name, sig } = JSON.parse(savedAuth);
connect(wallet, name, sig);
} catch (e) {
console.error('Failed to parse saved auth', e);
}
}
});
function sendMessage(content) {
if (!socket.value || !content.trim()) return;
// Simulate a blockchain transaction
console.log('Simulating 1 $PLEXUS transaction for message...');
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();
// Generate txId immediately for tracking and matching
const mockTxId = 'TX' + Math.random().toString(36).substring(2, 15).toUpperCase();
const pendingMsg = {
tempId,
channelId: currentChannel.value,
walletAddress: walletAddress.value,
username: username.value,
content,
timestamp: new Date().toISOString(),
status: 'pending',
txId: mockTxId,
reactions: []
};
setTimeout(() => {
// Randomly fail 5% of the time for demonstration
const failed = Math.random() < 0.05;
if (failed) {
const msg = messages.value[currentChannel.value].find(m => m.tempId === tempId);
if (msg) msg.status = 'failed';
console.error('Transaction failed!');
// Remove failed message after 5 seconds
setTimeout(() => {
messages.value[currentChannel.value] = messages.value[currentChannel.value].filter(m => m.tempId !== tempId);
}, 5000);
} else {
socket.value.emit('sendMessage', {
channelId: currentChannel.value,
walletAddress: walletAddress.value,
@@ -106,6 +231,8 @@ export const useChatStore = defineStore('chat', () => {
txId: mockTxId
});
}
}, 1500);
}
function toggleReaction(messageId, emoji) {
if (!socket.value) return;
@@ -114,6 +241,7 @@ export const useChatStore = defineStore('chat', () => {
console.log('Simulating 1 $PLEXUS transaction for reaction...');
socket.value.emit('toggleReaction', {
channelId: currentChannel.value,
messageId,
walletAddress: walletAddress.value,
emoji
@@ -134,22 +262,74 @@ export const useChatStore = defineStore('chat', () => {
});
}
// Listen for username updates
onMounted(() => {
if (socket.value) {
socket.value.on('usernameUpdated', ({ username: newName }) => {
username.value = newName;
// Update cookie
const authData = JSON.parse(Cookies.get('plexus_auth') || '{}');
authData.name = newName;
Cookies.set('plexus_auth', JSON.stringify(authData), { expires: 7 });
});
function getProfile(address) {
if (!socket.value) return;
isProfileLoading.value = true;
socket.value.emit('getProfile', address);
}
socket.value.on('error', (err) => {
alert(err.message || 'An error occurred');
function updateProfile(bio, bannerColor) {
if (!socket.value) return;
socket.value.emit('updateProfile', {
walletAddress: walletAddress.value,
bio,
bannerColor
});
}
function createPost(content) {
if (!socket.value || !content.trim()) return;
socket.value.emit('createPost', {
walletAddress: walletAddress.value,
content
});
}
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}`;
// Find the other user's name
const otherUser = users.value.find(u => u.wallet_address === targetWallet);
const otherName = otherUser?.username || targetWallet.slice(0, 6) + '...';
// Add to channels list if not exists
if (!channels.value.find(c => c.id === dmChannelId)) {
channels.value.push({
id: dmChannelId,
name: otherName,
isDM: true,
targetWallet
});
}
setChannel(dmChannelId);
return dmChannelId;
}
function setChannel(channelId) {
currentChannel.value = channelId;
@@ -161,7 +341,10 @@ export const useChatStore = defineStore('chat', () => {
async function fetchChannels() {
try {
const res = await fetch('/api/channels');
channels.value = await res.json();
const data = await res.json();
// Keep existing DM channels
const dms = channels.value.filter(c => c.id.startsWith('dm:'));
channels.value = [...data, ...dms];
} catch (e) {
console.error('Failed to fetch channels', e);
}
@@ -171,12 +354,68 @@ export const useChatStore = defineStore('chat', () => {
try {
const res = await fetch(`/api/messages/${channelId}`);
const data = await res.json();
messages.value[channelId] = data;
// Map snake_case to camelCase and set status
messages.value[channelId] = data.map(m => ({
...m,
walletAddress: m.wallet_address,
txId: m.tx_id,
channelId: m.channel_id,
status: 'validated' // Messages from DB are confirmed
}));
} catch (e) {
console.error('Failed to fetch messages', e);
}
}
function saveSession(wallet, name, sig) {
try {
localStorage.setItem('plexus_auth', JSON.stringify({ wallet, name, sig }));
} catch (e) {
console.error('Failed to save session', e);
}
}
function checkAuth() {
const savedAuth = localStorage.getItem('plexus_auth');
if (savedAuth) {
try {
const { wallet, name, sig } = JSON.parse(savedAuth);
if (wallet && name) {
if (!isConnected.value) {
connect(wallet, name, sig);
}
return true;
}
} catch (e) {
console.error('Failed to parse auth', e);
}
}
return false;
}
function logout() {
localStorage.removeItem('plexus_auth');
localStorage.removeItem('plexus_balance');
walletAddress.value = null;
username.value = null;
signature.value = null;
balance.value = 100;
isConnected.value = false;
if (socket.value) {
socket.value.disconnect();
}
// Router redirect will be handled by component or global guard
}
function setProfilePicture(url) {
profilePicture.value = url;
if (url) {
localStorage.setItem('plexus_nft_pic', url);
} else {
localStorage.removeItem('plexus_nft_pic');
}
}
return {
socket,
isConnected,
@@ -189,10 +428,26 @@ export const useChatStore = defineStore('chat', () => {
onlineUsers,
offlineUsers,
currentMessages,
profileUser,
profilePosts,
profileReposts,
isProfileLoading,
profilePicture,
connect,
sendMessage,
toggleReaction,
updateUsername,
setChannel
getProfile,
updateProfile,
createPost,
createComment,
fetchComments,
repost,
startDM,
setChannel,
setProfilePicture,
balance,
checkAuth,
logout
};
});

View File

@@ -3,7 +3,7 @@
@tailwind utilities;
body {
@apply bg-crypto-dark text-crypto-text overflow-hidden;
@apply bg-discord-dark text-crypto-text overflow-hidden antialiased;
}
/* Custom Scrollbar */
@@ -12,21 +12,48 @@ body {
}
::-webkit-scrollbar-track {
@apply bg-crypto-dark;
@apply bg-discord-black;
}
::-webkit-scrollbar-thumb {
@apply bg-crypto-panel rounded;
@apply bg-discord-sidebar rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-crypto-muted;
@apply bg-white/10;
}
.glass {
@apply backdrop-blur-md bg-white/5 border border-white/10;
@apply backdrop-blur-md bg-white/[0.03] border border-white/10;
}
.glass-panel {
@apply backdrop-blur-xl bg-black/40 border-r border-white/5;
@apply backdrop-blur-xl bg-discord-sidebar/80 border-r border-white/5;
}
.led {
@apply w-2 h-2 rounded-full shadow-lg;
}
.led-orange {
@apply bg-status-pending shadow-status-pending/50 animate-led-pulse;
}
.led-green {
@apply bg-status-validated shadow-status-validated/50;
}
.led-red {
@apply bg-status-failed shadow-status-failed/50 animate-pulse;
}
/* Mobile responsiveness helpers */
@media (max-width: 768px) {
.sidebar-hidden {
@apply -translate-x-full;
}
.sidebar-visible {
@apply translate-x-0;
}
}

View File

@@ -12,18 +12,34 @@ export default {
'crypto-accent': '#8b5cf6', // Violet
'crypto-text': '#e2e8f0',
'crypto-muted': '#94a3b8',
'discord-dark': '#313338',
'discord-sidebar': '#2b2d31',
'discord-black': '#1e1f22',
'status-pending': '#f59e0b', // Orange
'status-validated': '#10b981', // Green
'status-failed': '#ef4444', // Red
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
sans: ['Inter', 'sans-serif', 'system-ui'],
},
animation: {
'fade-in-up': 'fadeInUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'pop-in': 'popIn 0.2s cubic-bezier(0.26, 0.53, 0.74, 1.48)',
'led-pulse': 'ledPulse 2s infinite',
},
keyframes: {
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
popIn: {
'0%': { opacity: '0', transform: 'scale(0.8)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
ledPulse: {
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
'50%': { opacity: '0.6', transform: 'scale(0.95)' },
}
}
},

View File

@@ -1,18 +1,30 @@
version: '3.8'
services:
server:
api:
build: ./server
ports:
- "3000:3000"
volumes:
- ./data:/app/data
- ./server:/app
environment:
- PORT=3000
env_file:
- .env
client:
build: ./client
ports:
- "8080:80"
depends_on:
- server
- api
volumes:
- ./client:/app
dev-shell:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
environment:
- NODE_ENV=development

21
docs/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Plexus Documentation
Welcome to the official documentation for **Plexus**, a decentralized crypto chat application.
## 📖 Table of Contents
- [Architecture Overview](file:///home/sinan/Documents/repositories/local/plexus/docs/architecture.md) - How the system works.
- [Development Guide](file:///home/sinan/Documents/repositories/local/plexus/docs/development.md) - Guidelines for contributors.
- [Task Tracker](file:///home/sinan/Documents/repositories/local/plexus/docs/tasks.md) - How to use the internal task management system.
- [Scalability & Roadmap](file:///home/sinan/Documents/repositories/local/plexus/docs/scalability.md) - Thinking bigger.
## 🚀 Quick Start
To get started with development, please refer to the [Development Guide](file:///home/sinan/Documents/repositories/local/plexus/docs/development.md).
## 🛠 Tech Stack
- **Frontend**: Vue 3, Vite, TailwindCSS, Pinia.
- **Backend**: Node.js, Express, Socket.IO.
- **Database**: DuckDB (for chat history and task tracking).
- **Blockchain**: Solana (simulated transactions).

34
docs/architecture.md Normal file
View File

@@ -0,0 +1,34 @@
# Architecture Overview
Plexus is designed as a real-time, decentralized-first chat application.
## 🏗 System Components
### 1. Frontend (Client)
- Built with **Vue 3** and **Vite**.
- Uses **Pinia** for state management (messages, users, channels).
- Communicates with the backend via **Socket.IO** for real-time updates.
- Simulates Solana transactions for messages, reactions, and username changes.
### 2. Backend (Server)
- Built with **Node.js** and **Express**.
- Uses **Socket.IO** to handle real-time communication.
- **DuckDB** is used as the primary data store for:
- User profiles (wallet addresses, unique usernames).
- Message history.
- Reactions.
### 3. Database Schema
- `users`: `wallet_address` (PK), `username` (UNIQUE), `last_seen`.
- `messages`: `id` (PK), `channel_id`, `wallet_address`, `content`, `timestamp`, `tx_id`.
- `reactions`: `message_id`, `wallet_address`, `emoji`.
## 🔄 Data Flow
1. **User Joins**: Client emits `join` with wallet address and requested username. Server verifies/generates unique username and updates `users` table.
2. **Sending Message**: Client simulates transaction, then emits `sendMessage` with `txId`. Server saves message to DuckDB and broadcasts `newMessage` to all clients.
3. **Reactions**: Client emits `toggleReaction`. Server updates `reactions` table and broadcasts `updateReactions`.
## 🛡 Security
- Currently uses a simple wallet-based identification.
- Future versions will implement full message signing and verification.

43
docs/data-model.md Normal file
View File

@@ -0,0 +1,43 @@
# 📊 Data Model
Plexus uses **DuckDB** for fast, analytical, and serverless-friendly data storage.
## 🗄 Tables
### `users`
Stores user identity and profile information.
- `wallet_address` (VARCHAR, PK): Unique Solana wallet address.
- `username` (VARCHAR, UNIQUE): Display name.
- `bio` (VARCHAR): User biography.
- `banner_color` (VARCHAR): Hex code for profile banner.
- `last_seen` (TIMESTAMP): Last activity time.
### `messages`
Stores chat history.
- `id` (INTEGER, PK): Unique message ID (from `seq_msg_id`).
- `channel_id` (VARCHAR): ID of the channel.
- `wallet_address` (VARCHAR, FK): Sender's wallet.
- `content` (VARCHAR): Message text.
- `timestamp` (TIMESTAMP): Time sent.
- `tx_id` (VARCHAR): Simulated transaction ID.
### `reactions`
Stores message reactions.
- `message_id` (INTEGER, FK): ID of the message.
- `wallet_address` (VARCHAR, FK): User who reacted.
- `emoji` (VARCHAR): The emoji character.
- *Composite PK*: `(message_id, wallet_address, emoji)`.
### `posts`
Stores social wall posts.
- `id` (INTEGER, PK): Unique post ID (from `seq_post_id`).
- `wallet_address` (VARCHAR, FK): Owner of the wall.
- `content` (VARCHAR): Post text.
- `timestamp` (TIMESTAMP): Time posted.
## 🔢 Sequences
- `seq_msg_id`: Increments for each new message.
- `seq_post_id`: Increments for each new wall post.
## 🔄 Migrations
The `server/db.js` file handles automatic schema initialization and migrations (e.g., adding `tx_id` or `bio` columns if they are missing from an existing database).

36
docs/development.md Normal file
View File

@@ -0,0 +1,36 @@
# Development Guide
Welcome to the Plexus development team! Please follow these guidelines to ensure a consistent and high-quality codebase.
## 🛠 Setup
1. **Docker**: The easiest way to develop is using the dockerized shell.
```bash
make shell
```
2. **Local**: If you prefer local development:
```bash
make install
```
## 📝 Coding Standards
- **Linting**: Always run `make lint` before pushing.
- **Testing**: Add unit or integration tests for new features. Use `make test` to run them.
- **Documentation**: Update the `docs/` directory if you change architecture or add major features.
## 🌿 Git Workflow
- **Commits**: Use descriptive commit messages.
- **Task IDs**: Always include the task ID in your commit message if applicable.
- Example: `feat: add emoji picker (Task #123)`
- **Regular Commits**: Commit early and often. Small, atomic commits are preferred.
## 📋 Task Management
We use an internal task tracker powered by DuckDB.
- List tasks: `make task-list`
- Add task: `make task-add title="My new task"`
- Complete task: `make task-done id=1`
See [Task Tracker Documentation](file:///home/sinan/Documents/repositories/local/plexus/docs/tasks.md) for more details.

32
docs/functions.md Normal file
View File

@@ -0,0 +1,32 @@
# ⚙️ Functions & API
Plexus communicates primarily through WebSockets (Socket.io) for real-time interaction.
## 🔌 Socket Events
### 📥 Client to Server
- `join({ walletAddress, username })`: Register or login a user.
- `sendMessage({ channelId, walletAddress, content, txId })`: Send a message to a channel.
- `toggleReaction({ messageId, walletAddress, emoji })`: Toggle a reaction on a message.
- `updateUsername({ walletAddress, newUsername, txId })`: Change username (simulated cost).
- `getProfile(walletAddress)`: Fetch user profile and wall posts.
- `updateProfile({ walletAddress, bio, bannerColor })`: Update profile details.
- `createPost({ walletAddress, content })`: Post a message to the user's wall.
### 📤 Server to Client
- `userList(users)`: Broadcast the updated list of online/offline users.
- `newMessage(message)`: Broadcast a new message to all clients.
- `updateReactions({ messageId, reactions })`: Broadcast updated reactions for a message.
- `usernameUpdated({ username })`: Confirm a username change to the specific user.
- `profileData(data)`: Send requested profile data to a user.
- `profileUpdated(data)`: Confirm profile update.
- `postCreated(post)`: Confirm post creation.
- `error({ message })`: Send error messages to the client.
## 🌐 REST API
- `GET /api/channels`: List all available chat channels.
- `GET /api/messages/:channelId`: Fetch the last 100 messages and reactions for a channel.
## 🛠 Internal Logic
- **Username Authority**: The server validates and ensures unique usernames, appending suffixes if necessary.
- **Transaction Simulation**: The client simulates a 1.5s delay and a 5% failure rate for "blockchain" transactions.

30
docs/scalability.md Normal file
View File

@@ -0,0 +1,30 @@
# Thinking Bigger: Scalability & Future Roadmap
Plexus is currently a robust MVP, but to scale to millions of users, we need to consider several architectural improvements.
## 🚀 Scalability Improvements
### 1. Backend Scaling
- **Load Balancing**: Use Nginx or HAProxy to distribute traffic across multiple Node.js instances.
- **Redis for Pub/Sub**: Currently, Socket.IO broadcasts are limited to a single server. Integrating Redis will allow broadcasting across multiple server instances.
- **Database Sharding**: As the message history grows, sharding DuckDB or migrating to a distributed database like CockroachDB or ClickHouse (for analytics) may be necessary.
### 2. Frontend Performance
- **Code Splitting**: Use Vite's code-splitting features to reduce initial load times.
- **Service Workers**: Implement PWA features for offline support and push notifications.
- **Virtual Scrolling**: For channels with thousands of messages, implement virtual scrolling to maintain UI performance.
## 🛠 Advanced Tooling
### 1. CI/CD Pipeline
- **GitHub Actions**: Automate linting, testing, and deployment.
- **Automated Testing**: Expand test coverage with Playwright for E2E testing and Vitest for unit testing.
### 2. Monitoring & Logging
- **Prometheus & Grafana**: Track server performance and user metrics.
- **Sentry**: Real-time error tracking and reporting.
## 🌐 Decentralization Roadmap
- **IPFS Integration**: Store media files and message history on IPFS for true decentralization.
- **Smart Contracts**: Move the $PLEXUS transaction logic to on-chain Solana programs.
- **DAO Governance**: Implement a DAO to allow users to vote on protocol changes.

33
docs/structure.md Normal file
View File

@@ -0,0 +1,33 @@
# 📂 Project Structure
Plexus is organized into three main areas: the client, the server, and the internal tooling.
## 📁 Root Directory
- `Makefile`: Central automation script for installation, development, and tasks.
- `docker-compose.yml`: Defines the development environment services.
- `Dockerfile.dev`: Unified development shell with all dependencies.
- `package.json`: Root package for devtooling (Husky, lint-staged).
- `pyproject.toml`: Configuration for Python linting (Ruff).
## 📁 `client/` (Frontend)
Built with Vue 3 and Vite.
- `src/components/`: Reusable UI components (Chat, Profile, UserList, etc.).
- `src/stores/`: Pinia stores for state management (`chat.js`).
- `src/style.css`: Global styles and Tailwind utilities.
- `tailwind.config.js`: Custom theme and animations.
## 📁 `server/` (Backend)
Built with Node.js and Socket.io.
- `index.js`: Main entry point, socket event handlers.
- `db.js`: Database initialization and schema management.
- `data/`: Contains the DuckDB database file (`chat.duckdb`).
- `tests/`: Integration tests for socket events.
## 📁 `tasks/` (Internal Tooling)
Python-based task tracker.
- `cli.py`: Command-line interface for managing tasks.
- `db.py`: DuckDB backend for task storage.
- `tasks.duckdb`: Database for internal tasks.
## 📁 `docs/` (Documentation)
Linked Markdown files covering all aspects of the project.

26
docs/tasks.md Normal file
View File

@@ -0,0 +1,26 @@
# Task Tracker
Plexus uses a custom-built task tracking system to manage its development lifecycle. The data is stored in a local DuckDB database (`data/tasks.duckdb`).
## 🛠 Commands
You can interact with the task tracker using the `Makefile`:
| Command | Description |
|---------|-------------|
| `make task-list` | List all active tasks. |
| `make task-add title="..."` | Add a new task. |
| `make task-update id=... status="..."` | Update a task's status. |
| `make task-done id=...` | Mark a task as completed. |
| `make task-filter status="..."` | Filter tasks by status. |
## 📊 Task Statuses
- `todo`: Task is waiting to be started.
- `in-progress`: Task is currently being worked on.
- `done`: Task is completed.
- `blocked`: Task is waiting on external factors.
## 🐍 Implementation Details
The task tracker is implemented in Python and uses the `duckdb` library. The source code can be found in the `tasks/` directory.

34
docs/vision.md Normal file
View 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, ...

520
package-lock.json generated Normal file
View File

@@ -0,0 +1,520 @@
{
"name": "plexus",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"husky": "^9.1.7",
"lint-staged": "^16.2.7"
}
},
"node_modules/ansi-escapes": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
"integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^7.1.0",
"string-width": "^8.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true,
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/lint-staged": {
"version": "16.2.7",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
"integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^14.0.2",
"listr2": "^9.0.5",
"micromatch": "^4.0.8",
"nano-spawn": "^2.0.0",
"pidtree": "^0.6.0",
"string-argv": "^0.3.2",
"yaml": "^2.8.1"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
"eventemitter3": "^5.0.1",
"log-update": "^6.1.0",
"rfdc": "^1.4.1",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
"cli-cursor": "^5.0.0",
"slice-ansi": "^7.1.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nano-spawn": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidtree": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true,
"license": "MIT",
"bin": {
"pidtree": "bin/pidtree.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"devDependencies": {
"husky": "^9.1.7",
"lint-staged": "^16.2.7"
},
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*": "make lint test"
}
}

7
pyproject.toml Normal file
View File

@@ -0,0 +1,7 @@
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []

View File

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

View File

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

View File

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

View File

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

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