Compare commits

...

10 Commits

39 changed files with 3072 additions and 352 deletions

1
.gitignore vendored
View File

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

4
.husky/pre-commit Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

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.

44
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,44 @@
# 🤝 Contributing to Plexus
We maintain high standards for code quality and documentation. Please follow this workflow for all contributions.
## 🔄 Development Workflow
### 1. Pick a Task
Before coding, ensure you are working on an assigned task.
- List tasks: `make task-list`
- Add a task: `make task-add title="Your Task"`
- Mark as in-progress: (Update manually in `tasks/tasks.duckdb` or via CLI if implemented).
### 2. Implementation
- Write clean, modular code.
- Follow the existing design patterns (Vue 3 Composition API, Pinia, Socket.io).
- Ensure UI changes are mobile-friendly and follow the Discord-inspired aesthetic.
### 3. Verification
Before committing, you **must** verify your changes:
- Run linting: `make lint`
- Run tests: `make test`
- Manual check: Verify the feature in the browser.
### 4. Commit
We use **Husky** and **lint-staged** to enforce quality.
- Your commit will fail if linting or tests do not pass.
- Use descriptive commit messages (e.g., `feat: add user profiles`, `fix: message alignment`).
### 5. Documentation
If your change affects the architecture, data model, or API:
- Update the relevant file in `docs/`.
- Ensure the root `README.md` is still accurate.
### 6. Finalize Task
Mark the task as done:
- `make task-done id=X`
## 🛠 Tooling
- **Linting**: ESLint for JS/Vue, Ruff for Python.
- **Testing**: Mocha for backend integration tests.
- **Automation**: Use the `Makefile` for all common operations.
---
Thank you for helping make Plexus better!

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"]

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
.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
# Task Tracker Integration
PYTHON=python3
TASK_CLI=tasks/cli.py
task-list:
$(PYTHON) $(TASK_CLI) list
task-add:
$(PYTHON) $(TASK_CLI) add "$(title)"
task-done:
$(PYTHON) $(TASK_CLI) done $(id)
task-update:
$(PYTHON) $(TASK_CLI) update $(id) $(status)
task-delete:
$(PYTHON) $(TASK_CLI) delete $(id)
task-filter:
$(PYTHON) $(TASK_CLI) list --status $(status)

66
README.md Normal file
View File

@@ -0,0 +1,66 @@
# 🌌 Plexus
Plexus is a premium, decentralized-inspired chat application built with **Vue 3**, **Node.js**, **Socket.io**, and **DuckDB**. It features a sleek Discord-style interface, real-time messaging, and social profiles with customizable "walls".
![Desktop View](file:///home/sinan/.gemini/antigravity/brain/d2723a70-2b81-4f4a-b974-6f0dc17d1fae/desktop_view_1768342632058.png)
## 🚀 Key Features
- **💎 Premium UI**: Discord-inspired dark theme with glassmorphism and smooth animations.
- **📱 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) with LED indicators.
- **🛠 Robust Tooling**: Automated linting, testing, and a custom internal task tracker.
## 🛠 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:
- [🏗 Architecture](file:///home/sinan/Documents/repositories/local/plexus/docs/architecture.md) - High-level system design.
- [📂 Structure](file:///home/sinan/Documents/repositories/local/plexus/docs/structure.md) - Directory and file organization.
- [⚙️ Functions & API](file:///home/sinan/Documents/repositories/local/plexus/docs/functions.md) - Socket events and backend logic.
- [📊 Data Model](file:///home/sinan/Documents/repositories/local/plexus/docs/data-model.md) - Database schema and migrations.
- [📈 Scalability](file:///home/sinan/Documents/repositories/local/plexus/docs/scalability.md) - Future roadmap and scaling strategies.
- [📝 Task Tracker](file:///home/sinan/Documents/repositories/local/plexus/docs/tasks.md) - How to use the internal task management tool.
## 🚦 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
# Or run locally
cd server && npm run dev
cd client && npm run dev
```
## 🤝 Contributing
We follow a strict development workflow. Please read [CONTRIBUTING.md](file:///home/sinan/Documents/repositories/local/plexus/CONTRIBUTING.md) before starting.
1. **Pick a Task**: Use `make task-list` to find something to work on.
2. **Code**: Implement your changes.
3. **Verify**: Run `make lint test` to ensure quality.
4. **Commit**: Pre-commit hooks will automatically run linting and tests.
5. **Document**: Update relevant docs if you add new features.
---
Built with ❤️ by the Plexus Team.

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',
},
};

1162
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint . --ext .js,.vue --fix"
}, },
"dependencies": { "dependencies": {
"@solana/web3.js": "^1.98.4", "@solana/web3.js": "^1.98.4",
@@ -21,8 +22,10 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.21.1",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }

View File

@@ -7,7 +7,7 @@ import ChatLayout from './components/ChatLayout.vue';
const chatStore = useChatStore(); const chatStore = useChatStore();
const videoRef = ref(null); const videoRef = ref(null);
const handleMuteToggle = (isMuted) => { const handleMuteToggle = () => {
if (videoRef.value) { if (videoRef.value) {
// Note: YouTube iframe API would be needed for true control, // 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. // but for a simple background video loop, we can't easily unmute a background iframe without user interaction policies.
@@ -27,15 +27,18 @@ const handleMuteToggle = (isMuted) => {
frameborder="0" frameborder="0"
allow="autoplay; encrypted-media" allow="autoplay; encrypted-media"
allowfullscreen allowfullscreen
></iframe> />
<!-- Overlay gradient --> <!-- 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> </div>
<!-- Content --> <!-- Content -->
<div class="relative z-10 h-full"> <div class="relative z-10 h-full">
<WalletConnect v-if="!chatStore.isConnected" /> <WalletConnect v-if="!chatStore.isConnected" />
<ChatLayout v-else @toggleMute="handleMuteToggle" /> <ChatLayout
v-else
@toggle-mute="handleMuteToggle"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -4,11 +4,12 @@ import { storeToRefs } from 'pinia';
import MessageList from './MessageList.vue'; import MessageList from './MessageList.vue';
import UserList from './UserList.vue'; import UserList from './UserList.vue';
import MusicPlayer from './MusicPlayer.vue'; import MusicPlayer from './MusicPlayer.vue';
import TokenCreator from './TokenCreator.vue'; import { Hash, Volume2, VolumeX, Settings, X, Menu, User } from 'lucide-vue-next';
import { Hash, Volume2, VolumeX, Settings, X, Coins } from 'lucide-vue-next';
import { ref } from 'vue'; import { ref } from 'vue';
const showTokenCreator = ref(false); const showProfile = ref(false);
const selectedProfileAddress = ref(null);
const showMobileMenu = ref(false);
const chatStore = useChatStore(); const chatStore = useChatStore();
const { channels, currentChannel, username, walletAddress } = storeToRefs(chatStore); const { channels, currentChannel, username, walletAddress } = storeToRefs(chatStore);
@@ -34,13 +35,28 @@ const saveSettings = () => {
</script> </script>
<template> <template>
<div class="flex h-screen w-full overflow-hidden relative"> <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"
/>
<!-- Settings Modal --> <!-- 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
<div class="bg-crypto-panel border border-white/10 rounded-2xl w-full max-w-md shadow-2xl animate-fade-in-up"> 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"> <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> <h2 class="text-xl font-bold text-white">
<button @click="showSettings = false" class="text-gray-400 hover:text-white transition-colors"> Profile Settings
</h2>
<button
class="text-gray-400 hover:text-white transition-colors"
@click="showSettings = false"
>
<X size="24" /> <X size="24" />
</button> </button>
</div> </div>
@@ -50,27 +66,27 @@ const saveSettings = () => {
<input <input
v-model="newUsername" v-model="newUsername"
type="text" 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" placeholder="Enter new username"
/> >
</div> </div>
<div> <div>
<label class="block text-xs font-bold text-crypto-muted uppercase tracking-wider mb-2">Wallet Address</label> <label class="block text-xs font-bold text-crypto-muted uppercase tracking-wider mb-2">Wallet Address</label>
<div class="w-full bg-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 }} {{ walletAddress }}
</div> </div>
</div> </div>
</div> </div>
<div class="p-6 border-t border-white/5 flex gap-3"> <div class="p-6 border-t border-white/5 flex gap-3">
<button <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" 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 Cancel
</button> </button>
<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" 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 Save Changes
</button> </button>
@@ -79,76 +95,128 @@ const saveSettings = () => {
</div> </div>
<!-- Channels Sidebar --> <!-- 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"> <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> <h1 class="font-bold text-white truncate">
<button @click="toggleMute" class="text-gray-400 hover:text-gray-200 transition-colors"> Plexus Server
<VolumeX v-if="isMuted" size="18" /> </h1>
<Volume2 v-else size="18" /> <button
class="text-gray-400 hover:text-gray-200 transition-colors"
@click="toggleMute"
>
<VolumeX
v-if="isMuted"
size="18"
/>
<Volume2
v-else
size="18"
/>
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto py-3 space-y-0.5 px-2"> <div class="flex-1 overflow-y-auto py-3 space-y-0.5 px-2">
<!-- Token Creator Link --> <!-- Profile Link -->
<button <button
@click="showTokenCreator = true"
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group mb-4', :class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group mb-4',
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="selectedProfileAddress = walletAddress; showProfile = true; showMobileMenu = false"
> >
<Coins size="18" class="text-violet-400" /> <User
<span class="text-sm font-medium">Token Creator</span> size="18"
class="text-violet-400"
/>
<span class="text-sm font-medium">My Profile</span>
</button> </button>
<div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">Text Channels</div> <div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">
<div v-for="channel in channels" :key="channel.id"> Text Channels
</div>
<div
v-for="channel in channels"
:key="channel.id"
>
<button <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', :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']" currentChannel === channel.id && !showProfile ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
@click="chatStore.setChannel(channel.id); showProfile = false; showMobileMenu = false"
> >
<Hash size="18" :class="currentChannel === channel.id && !showTokenCreator ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'" /> <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> <span class="text-sm font-medium">{{ channel.name }}</span>
</button> </button>
</div> </div>
</div> </div>
<!-- Music Player & Profile --> <!-- Music Player & Profile -->
<div class="bg-[#232428] p-2 space-y-2"> <div class="bg-discord-black p-2 space-y-2">
<MusicPlayer /> <MusicPlayer />
<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="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="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="w-8 h-8 rounded-full bg-violet-600 flex items-center justify-center text-white text-xs font-bold">
{{ username?.substring(0, 2).toUpperCase() }} {{ username?.substring(0, 2).toUpperCase() }}
</div> </div>
<div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-[#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>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-xs font-bold text-white truncate">{{ username }}</div> <div class="text-xs font-bold text-white truncate">
<div class="text-[10px] text-gray-400 truncate">#{{ walletAddress?.slice(-4) }}</div> {{ username }}
</div>
<div class="text-[10px] text-gray-400 truncate">
#{{ walletAddress?.slice(-4) }}
</div>
</div> </div>
<Settings size="14" class="text-gray-400 group-hover:text-gray-200" /> <Settings
size="14"
class="text-gray-400 group-hover:text-gray-200"
/>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Content --> <!-- 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 --> <!-- 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"> <div class="h-12 px-4 flex items-center border-b border-black/20 shadow-sm bg-discord-dark/95 backdrop-blur-sm z-10">
<Hash size="20" class="text-gray-400 mr-2" /> <button
<span class="font-bold text-white mr-4">{{ showTokenCreator ? 'Token Creator' : currentChannel }}</span> 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>
</div> </div>
<div class="flex-1 flex overflow-hidden"> <div class="flex-1 flex overflow-hidden">
<div class="flex-1 flex flex-col relative overflow-hidden"> <div class="flex-1 flex flex-col relative overflow-hidden">
<TokenCreator v-if="showTokenCreator" @back="showTokenCreator = false" /> <UserProfile
<MessageList v-else /> v-if="showProfile"
:address="selectedProfileAddress"
/>
<MessageList
v-else
@view-profile="(addr) => { selectedProfileAddress = addr; showProfile = true; }"
/>
</div> </div>
<!-- Member List (Discord Style) --> <!-- Member List (Discord Style) -->
<div class="w-60 bg-[#2b2d31] border-l border-black/20 hidden lg:flex flex-col"> <div class="w-60 bg-discord-sidebar border-l border-black/20 hidden xl:flex flex-col">
<UserList /> <UserList @view-profile="(addr) => { selectedProfileAddress = addr; showProfile = true; }" />
</div> </div>
</div> </div>
</div> </div>

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

@@ -50,68 +50,108 @@ const send = () => {
const formatTime = (isoString) => { const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}; };
const emit = defineEmits(['view-profile']);
</script> </script>
<template> <template>
<div class="flex-1 flex flex-col h-full bg-black/40 backdrop-blur-sm relative z-10"> <div class="flex-1 flex flex-col h-full bg-discord-dark relative z-10">
<!-- Header --> <!-- Header (Desktop only, mobile header is in ChatLayout) -->
<div class="h-12 border-b border-white/5 flex items-center px-4 shadow-sm bg-crypto-panel/50"> <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-lg font-bold text-white"># {{ currentChannel }}</div> <div class="text-sm font-bold text-white flex items-center gap-2">
<Hash
size="18"
class="text-gray-400"
/>
{{ currentChannel }}
</div>
</div> </div>
<!-- Messages --> <!-- 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 --> <!-- Beginning of conversation marker -->
<div class="py-12 px-4 border-b border-white/5 mb-8"> <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"> <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" /> <Hash size="32" />
</div> </div>
<h2 class="text-2xl font-bold text-white mb-1">Welcome to #{{ currentChannel }}!</h2> <h2 class="text-3xl font-bold text-white mb-2">
<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> 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>
<div v-for="(msg, index) in currentMessages" :key="msg.id" <div
v-for="(msg, index) in currentMessages"
:key="msg.id || msg.tempId"
class="group flex gap-4 px-4 py-1 hover:bg-white/[0.02] transition-colors relative" class="group flex gap-4 px-4 py-1 hover:bg-white/[0.02] transition-colors relative"
> >
<!-- Avatar (only if first message in group) --> <!-- Avatar (only if first message in group) -->
<div class="w-10 flex-shrink-0"> <div class="w-10 flex-shrink-0">
<div v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress" <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" v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
:class="msg.walletAddress === walletAddress ? 'bg-gradient-to-br from-violet-500 to-fuchsia-600' : 'bg-crypto-panel'" 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() }} {{ msg.username?.substring(0, 2).toUpperCase() }}
</div> </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"> <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) }} {{ formatTime(msg.timestamp) }}
</div> </div>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-1 min-w-0"> <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"> <div
<span :class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']"> v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
class="flex items-center gap-2 mb-0.5"
>
<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 }} {{ msg.username }}
</span> </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) }}
</span>
<span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</span> <span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</span>
<!-- Status LED -->
<div
v-if="msg.status"
class="led ml-1"
:class="{
'led-orange': msg.status === 'pending',
'led-green': msg.status === 'validated',
'led-red': msg.status === 'failed'
}"
:title="msg.status"
/>
</div> </div>
<div class="text-gray-100 text-sm leading-relaxed break-words"> <div :class="['text-sm leading-relaxed break-words', msg.status === 'failed' ? 'text-status-failed line-through opacity-60' : 'text-gray-100']">
{{ msg.content }} {{ msg.content }}
</div> </div>
<!-- Reactions Display --> <!-- 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-1.5"
>
<button <button
v-for="emoji in getUniqueEmojis(msg.reactions)" v-for="emoji in getUniqueEmojis(msg.reactions)"
:key="emoji" :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 py-0.5 rounded-lg text-xs border transition-all animate-pop-in',
hasUserReacted(msg.reactions, emoji) hasUserReacted(msg.reactions, emoji)
? 'bg-violet-500/20 border-violet-500/50 text-violet-300' ? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10']" : 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10']"
@click="toggleReaction(msg.id, emoji)"
> >
<span>{{ emoji }}</span> <span>{{ emoji }}</span>
<span class="font-bold">{{ getReactionCount(msg.reactions, emoji) }}</span> <span class="font-bold">{{ getReactionCount(msg.reactions, emoji) }}</span>
@@ -120,22 +160,28 @@ const formatTime = (isoString) => {
</div> </div>
<!-- Hover Actions --> <!-- 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'"
class="absolute right-4 -top-4 opacity-0 group-hover:opacity-100 transition-opacity z-20 flex gap-1 bg-discord-sidebar border border-white/10 rounded-lg p-1 shadow-xl"
>
<button <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-1.5 hover:bg-white/10 rounded text-gray-400 hover:text-white transition-all"
title="Add Reaction" title="Add Reaction"
@click="showEmojiPicker = showEmojiPicker === msg.id ? null : msg.id"
> >
<Smile size="16" /> <Smile size="16" />
</button> </button>
<!-- Emoji Picker Popover --> <!-- 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 <button
v-for="emoji in EMOJIS" v-for="emoji in EMOJIS"
:key="emoji" :key="emoji"
@click="toggleReaction(msg.id, emoji)"
class="hover:scale-125 transition-transform p-1 text-lg" class="hover:scale-125 transition-transform p-1 text-lg"
@click="toggleReaction(msg.id, emoji)"
> >
{{ emoji }} {{ emoji }}
</button> </button>
@@ -145,22 +191,33 @@ const formatTime = (isoString) => {
</div> </div>
<!-- Input --> <!-- Input -->
<div class="p-4 bg-crypto-panel/80 border-t border-white/5"> <div class="p-4 bg-discord-dark">
<div class="relative"> <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">
<input <input
v-model="newMessage" v-model="newMessage"
type="text"
:placeholder="`Message #${currentChannel}`"
class="w-full bg-transparent text-white placeholder-gray-500 py-3 pl-4 pr-12 focus:outline-none"
@keyup.enter="send" @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"
/>
<button <button
class="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-gray-400 hover:text-violet-400 transition-colors"
@click="send" @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" /> <Send size="20" />
</button> </button>
</div> </div>
<div class="mt-2 flex items-center gap-4 px-1">
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-orange w-1.5 h-1.5" /> Pending
</div>
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-green w-1.5 h-1.5" /> Validated
</div>
<div class="flex items-center gap-1.5 text-[10px] text-gray-500 uppercase tracking-widest font-bold">
<div class="led led-red w-1.5 h-1.5" /> Failed
</div>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -51,41 +51,66 @@ const updateVolume = () => {
class="hidden" 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/${LOFI_VIDEOS[currentVideoIndex]}?enablejsapi=1&autoplay=0&controls=0&disablekb=1&fs=0&modestbranding=1&iv_load_policy=3`"
frameborder="0" 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"> <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" /> <Music size="24" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm font-bold text-white truncate">Lofi Radio</div> <div class="text-sm font-bold text-white truncate">
<div class="text-[10px] text-crypto-muted uppercase tracking-wider">Chilling in the Nebula</div> Lofi Radio
</div>
<div class="text-[10px] text-crypto-muted uppercase tracking-wider">
Chilling in the Nebula
</div>
</div> </div>
<div class="flex items-center gap-2"> <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"> <button
<Pause v-if="isPlaying" size="18" /> class="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all"
<Play v-else size="18" /> @click="togglePlay"
>
<Pause
v-if="isPlaying"
size="18"
/>
<Play
v-else
size="18"
/>
</button> </button>
<button @click="nextTrack" class="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-white transition-all"> <button
class="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-white transition-all"
@click="nextTrack"
>
<SkipForward size="18" /> <SkipForward size="18" />
</button> </button>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center gap-3"> <div class="mt-4 flex items-center gap-3">
<button @click="toggleMute" class="text-gray-400 hover:text-white transition-colors"> <button
<VolumeX v-if="isMuted || volume == 0" size="16" /> class="text-gray-400 hover:text-white transition-colors"
<Volume2 v-else size="16" /> @click="toggleMute"
>
<VolumeX
v-if="isMuted || volume == 0"
size="16"
/>
<Volume2
v-else
size="16"
/>
</button> </button>
<input <input
v-model="volume" v-model="volume"
@input="updateVolume" type="range"
type="range"
min="0" min="0"
max="100" max="100"
class="flex-1 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-indigo-500" class="flex-1 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/> @input="updateVolume"
>
</div> </div>
</div> </div>
</template> </template>

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

@@ -4,6 +4,8 @@ import { storeToRefs } from 'pinia';
const chatStore = useChatStore(); const chatStore = useChatStore();
const { onlineUsers, offlineUsers } = storeToRefs(chatStore); const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
const emit = defineEmits(['view-profile']);
</script> </script>
<template> <template>
@@ -11,14 +13,21 @@ const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
<div class="flex-1 overflow-y-auto p-3 space-y-6"> <div class="flex-1 overflow-y-auto p-3 space-y-6">
<!-- Online Users --> <!-- Online Users -->
<div v-if="onlineUsers.length > 0"> <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 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"
@click="emit('view-profile', user.wallet_address)"
>
<div class="relative"> <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"> <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() }} {{ user.username.substring(0, 2).toUpperCase() }}
</div> </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>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-300 truncate group-hover:text-white transition-colors"> <div class="text-sm font-medium text-gray-300 truncate group-hover:text-white transition-colors">
@@ -31,14 +40,21 @@ const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
<!-- Offline Users --> <!-- Offline Users -->
<div v-if="offlineUsers.length > 0"> <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 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"
@click="emit('view-profile', user.wallet_address)"
>
<div class="relative"> <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"> <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() }} {{ user.username.substring(0, 2).toUpperCase() }}
</div> </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>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-500 truncate group-hover:text-gray-400"> <div class="text-sm font-medium text-gray-500 truncate group-hover:text-gray-400">

View File

@@ -0,0 +1,232 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { MessageSquare, Calendar, Edit3, Send, X } from 'lucide-vue-next';
const props = defineProps({
address: {
type: String,
required: true
}
});
const chatStore = useChatStore();
const { profileUser, profilePosts, isProfileLoading, walletAddress } = storeToRefs(chatStore);
const isEditing = ref(false);
const editBio = ref('');
const editBannerColor = ref('');
const newPostContent = ref('');
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';
isEditing.value = true;
};
const saveProfile = () => {
chatStore.updateProfile(editBio.value, editBannerColor.value);
isEditing.value = false;
};
const submitPost = () => {
if (!newPostContent.value.trim()) return;
chatStore.createPost(newPostContent.value);
newPostContent.value = '';
};
const formatTime = (isoString) => {
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
</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 -bottom-16 left-8">
<div class="w-32 h-32 rounded-full border-8 border-discord-dark bg-violet-600 flex items-center justify-center text-white text-4xl font-bold shadow-xl">
{{ 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>
<p class="text-gray-400 font-mono text-sm mt-1">
{{ profileUser.wallet_address }}
</p>
</div>
<button
v-if="profileUser.wallet_address === walletAddress"
class="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-white text-sm font-medium transition-all flex items-center gap-2"
@click="startEditing"
>
<Edit3 size="16" /> Edit Profile
</button>
</div>
<div class="mt-4 text-gray-200 text-lg leading-relaxed max-w-2xl">
{{ profileUser.bio || 'No bio yet...' }}
</div>
<div class="mt-6 flex flex-wrap gap-4 text-gray-400 text-sm">
<div class="flex items-center gap-1.5">
<Calendar size="16" /> Joined {{ new Date(profileUser.last_seen).toLocaleDateString() }}
</div>
</div>
</div>
<!-- Wall / Posts -->
<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="profileUser.wallet_address === walletAddress"
class="mb-8 bg-discord-sidebar/30 rounded-2xl p-4 border border-white/5"
>
<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-violet-600 hover:bg-violet-500 text-white rounded-full font-bold transition-all flex items-center gap-2 shadow-lg shadow-violet-600/20"
@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-white/10 transition-all group"
>
<div class="flex justify-between items-start mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-violet-600/20 flex items-center justify-center text-violet-400 font-bold">
{{ 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>
</div>
<div class="text-gray-200 leading-relaxed">
{{ post.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">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,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref } from 'vue';
import { useChatStore } from '../stores/chat'; import { useChatStore } from '../stores/chat';
const chatStore = useChatStore(); const chatStore = useChatStore();
@@ -42,19 +42,28 @@ const connectWallet = async () => {
<template> <template>
<div class="flex flex-col items-center justify-center h-screen bg-black/50 backdrop-blur-sm"> <div class="flex flex-col items-center justify-center h-screen bg-black/50 backdrop-blur-sm">
<div class="p-8 bg-crypto-panel rounded-xl shadow-2xl border border-crypto-accent/20 text-center max-w-md w-full"> <div class="p-8 bg-crypto-panel rounded-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> <h1 class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-400 to-pink-600 text-transparent bg-clip-text">
<p class="text-crypto-muted mb-8">Connect your wallet to join the conversation.</p> Crypto Chat
</h1>
<p class="text-crypto-muted mb-8">
Connect your wallet to join the conversation.
</p>
<button <button
@click="connectWallet" :disabled="isConnecting"
:disabled="isConnecting"
class="w-full py-3 px-6 bg-crypto-accent hover:bg-violet-600 text-white rounded-lg font-semibold transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" class="w-full py-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"
@click="connectWallet"
> >
<span v-if="isConnecting">Connecting...</span> <span v-if="isConnecting">Connecting...</span>
<span v-else>Connect Phantom Wallet</span> <span v-else>Connect Phantom Wallet</span>
</button> </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"
>
{{ error }}
</p>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { ref, computed, onMounted } from 'vue'; import { ref, computed } from 'vue';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
@@ -15,6 +15,11 @@ export const useChatStore = defineStore('chat', () => {
const users = ref([]); const users = ref([]);
const channels = ref([]); const channels = ref([]);
// Profile state
const profileUser = ref(null);
const profilePosts = ref([]);
const isProfileLoading = ref(false);
const onlineUsers = computed(() => users.value.filter(u => u.online)); const onlineUsers = computed(() => users.value.filter(u => u.online));
const offlineUsers = computed(() => users.value.filter(u => !u.online)); const offlineUsers = computed(() => users.value.filter(u => !u.online));
@@ -45,8 +50,19 @@ export const useChatStore = defineStore('chat', () => {
if (!messages.value[message.channelId]) { if (!messages.value[message.channelId]) {
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 content and wallet)
// In a real app, we'd use the txId to match
const pendingIdx = messages.value[message.channelId].findIndex(
m => m.status === 'pending' && m.content === message.content && m.walletAddress === message.walletAddress
);
if (pendingIdx !== -1) {
// Update the pending message with server data and mark as validated
messages.value[message.channelId][pendingIdx] = { ...message, status: 'validated' };
} else {
messages.value[message.channelId] = [...messages.value[message.channelId], { ...message, status: 'validated' }];
}
}); });
socket.value.on('userList', (userList) => { socket.value.on('userList', (userList) => {
@@ -63,48 +79,90 @@ export const useChatStore = defineStore('chat', () => {
} }
}); });
socket.value.on('profileData', (data) => {
profileUser.value = data;
profilePosts.value = data.posts;
isProfileLoading.value = false;
});
socket.value.on('profileUpdated', (data) => {
if (profileUser.value && profileUser.value.wallet_address === walletAddress.value) {
profileUser.value = { ...profileUser.value, ...data };
}
});
socket.value.on('postCreated', (post) => {
profilePosts.value = [post, ...profilePosts.value];
});
socket.value.on('usernameUpdated', ({ username: newName }) => {
username.value = newName;
const savedAuth = Cookies.get('plexus_auth');
if (savedAuth) {
const authData = JSON.parse(savedAuth);
authData.name = newName;
Cookies.set('plexus_auth', JSON.stringify(authData), { expires: 7 });
}
});
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(); fetchChannels();
fetchMessages(currentChannel.value); 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) { function sendMessage(content) {
if (!socket.value || !content.trim()) return; if (!socket.value || !content.trim()) return;
// Simulate a blockchain transaction const tempId = 'temp-' + Date.now();
const pendingMsg = {
tempId,
channelId: currentChannel.value,
walletAddress: walletAddress.value,
username: username.value,
content,
timestamp: new Date().toISOString(),
status: 'pending',
reactions: []
};
// Add to local state immediately
if (!messages.value[currentChannel.value]) {
messages.value[currentChannel.value] = [];
}
messages.value[currentChannel.value].push(pendingMsg);
// Simulate a blockchain transaction delay
console.log('Simulating 1 $PLEXUS transaction for message...'); console.log('Simulating 1 $PLEXUS transaction for message...');
const mockTxId = 'TX' + Math.random().toString(36).substring(2, 15).toUpperCase(); const mockTxId = 'TX' + Math.random().toString(36).substring(2, 15).toUpperCase();
socket.value.emit('sendMessage', { setTimeout(() => {
channelId: currentChannel.value, // Randomly fail 5% of the time for demonstration
walletAddress: walletAddress.value, const failed = Math.random() < 0.05;
content,
txId: mockTxId 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,
content,
txId: mockTxId
});
}
}, 1500);
} }
function toggleReaction(messageId, emoji) { function toggleReaction(messageId, emoji) {
@@ -134,22 +192,28 @@ export const useChatStore = defineStore('chat', () => {
}); });
} }
// Listen for username updates function getProfile(address) {
onMounted(() => { if (!socket.value) return;
if (socket.value) { isProfileLoading.value = true;
socket.value.on('usernameUpdated', ({ username: newName }) => { socket.value.emit('getProfile', address);
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 });
});
socket.value.on('error', (err) => { function updateProfile(bio, bannerColor) {
alert(err.message || 'An error occurred'); 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 setChannel(channelId) { function setChannel(channelId) {
currentChannel.value = channelId; currentChannel.value = channelId;
@@ -189,10 +253,16 @@ export const useChatStore = defineStore('chat', () => {
onlineUsers, onlineUsers,
offlineUsers, offlineUsers,
currentMessages, currentMessages,
profileUser,
profilePosts,
isProfileLoading,
connect, connect,
sendMessage, sendMessage,
toggleReaction, toggleReaction,
updateUsername, updateUsername,
getProfile,
updateProfile,
createPost,
setChannel setChannel
}; };
}); });

View File

@@ -3,7 +3,7 @@
@tailwind utilities; @tailwind utilities;
body { body {
@apply bg-crypto-dark text-crypto-text overflow-hidden; @apply bg-discord-dark text-crypto-text overflow-hidden antialiased;
} }
/* Custom Scrollbar */ /* Custom Scrollbar */
@@ -12,21 +12,48 @@ body {
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@apply bg-crypto-dark; @apply bg-discord-black;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-crypto-panel rounded; @apply bg-discord-sidebar rounded-full;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-crypto-muted; @apply bg-white/10;
} }
.glass { .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 { .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-accent': '#8b5cf6', // Violet
'crypto-text': '#e2e8f0', 'crypto-text': '#e2e8f0',
'crypto-muted': '#94a3b8', '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: { fontFamily: {
sans: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-serif', 'system-ui'],
}, },
animation: { animation: {
'fade-in-up': 'fadeInUp 0.3s ease-out', 'fade-in-up': 'fadeInUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', '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: { keyframes: {
fadeInUp: { fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' }, '0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }, '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

@@ -16,3 +16,12 @@ services:
- "8080:80" - "8080:80"
depends_on: depends_on:
- server - server
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.

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

@@ -11,6 +11,8 @@ con.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
wallet_address VARCHAR PRIMARY KEY, wallet_address VARCHAR PRIMARY KEY,
username VARCHAR UNIQUE, username VARCHAR UNIQUE,
bio VARCHAR DEFAULT '',
banner_color VARCHAR DEFAULT '#6366f1',
last_seen TIMESTAMP last_seen TIMESTAMP
); );
@@ -31,8 +33,17 @@ con.exec(`
PRIMARY KEY (message_id, wallet_address, emoji), PRIMARY KEY (message_id, wallet_address, emoji),
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address) FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
); );
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1; CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1;
CREATE SEQUENCE IF NOT EXISTS seq_post_id START 1;
-- Migration: Add tx_id to messages if it doesn't exist (for existing DBs) -- Migration: Add tx_id to messages if it doesn't exist (for existing DBs)
PRAGMA table_info('messages'); PRAGMA table_info('messages');
@@ -46,7 +57,20 @@ con.exec(`
if (!hasTxId) { if (!hasTxId) {
con.run("ALTER TABLE messages ADD COLUMN tx_id VARCHAR", (err) => { con.run("ALTER TABLE messages ADD COLUMN tx_id VARCHAR", (err) => {
if (err) console.error("Error adding tx_id column:", err); if (err) console.error("Error adding tx_id column:", err);
else console.log("Added tx_id column to messages table"); });
}
});
// Migration: Add bio and banner_color to users
con.all("PRAGMA table_info('users')", (err, rows) => {
if (err) return;
const hasBio = rows.some(r => r.name === 'bio');
if (!hasBio) {
con.run("ALTER TABLE users ADD COLUMN bio VARCHAR DEFAULT ''", (err) => {
if (err) console.error("Error adding bio column:", err);
});
con.run("ALTER TABLE users ADD COLUMN banner_color VARCHAR DEFAULT '#6366f1'", (err) => {
if (err) console.error("Error adding banner_color column:", err);
}); });
} }
}); });

View File

@@ -51,11 +51,13 @@ io.on('connection', (socket) => {
if (rows.length > 0) { if (rows.length > 0) {
// User exists, update last seen // User exists, update last seen
const existingUsername = rows[0].username;
con.prepare(`UPDATE users SET last_seen = ? WHERE wallet_address = ?`, (err, uStmt) => { con.prepare(`UPDATE users SET last_seen = ? WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return console.error("Prepare error:", err); if (err) return console.error("Prepare error:", err);
uStmt.run(now, walletAddress, (err) => { uStmt.run(now, walletAddress, (err) => {
uStmt.finalize(); uStmt.finalize();
if (err) console.error("Update error:", err); if (err) console.error("Update error:", err);
socket.emit('usernameUpdated', { username: existingUsername });
broadcastUserList(); broadcastUserList();
}); });
}); });
@@ -77,6 +79,7 @@ io.on('connection', (socket) => {
iStmt.run(walletAddress, finalUsername, now, (err) => { iStmt.run(walletAddress, finalUsername, now, (err) => {
iStmt.finalize(); iStmt.finalize();
if (err) console.error("Insert error:", err); if (err) console.error("Insert error:", err);
socket.emit('usernameUpdated', { username: finalUsername });
broadcastUserList(); broadcastUserList();
}); });
}); });
@@ -165,6 +168,61 @@ io.on('connection', (socket) => {
}); });
}); });
socket.on('getProfile', (walletAddress) => {
console.log(`Fetching profile for ${walletAddress}`);
con.prepare(`SELECT wallet_address, username, bio, banner_color, last_seen FROM users WHERE wallet_address = ?`, (err, stmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.all(walletAddress, (err, rows) => {
stmt.finalize();
if (err || rows.length === 0) return socket.emit('error', { message: 'User not found' });
const user = rows[0];
// Fetch posts
con.prepare(`SELECT * FROM posts WHERE wallet_address = ? ORDER BY timestamp DESC LIMIT 50`, (err, pStmt) => {
if (err) return socket.emit('profileData', { ...user, posts: [] });
pStmt.all(walletAddress, (err, posts) => {
pStmt.finalize();
socket.emit('profileData', { ...user, posts: posts || [] });
});
});
});
});
});
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 }) => {
if (!content || content.trim() === '') return;
console.log(`New post from ${walletAddress}: ${content}`);
const timestamp = new Date().toISOString();
con.prepare(`INSERT INTO posts (id, wallet_address, content, timestamp) VALUES (nextval('seq_post_id'), ?, ?, ?)`, (err, stmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.run(walletAddress, content, timestamp, (err) => {
stmt.finalize();
if (err) return socket.emit('error', { message: 'Failed to create post' });
// Fetch all posts to broadcast update or just emit the new one
// For simplicity, we'll just tell the user it was created
socket.emit('postCreated', { content, timestamp });
// If we want a live feed, we could broadcast to a "profile room"
// For now, the user can just refresh or we emit to them
});
});
});
socket.on('toggleReaction', ({ messageId, walletAddress, emoji }) => { socket.on('toggleReaction', ({ messageId, walletAddress, emoji }) => {
console.log(`Toggling reaction: ${emoji} on message ${messageId} by ${walletAddress}`); 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) => { con.prepare(`SELECT * FROM reactions WHERE message_id = ? AND wallet_address = ? AND emoji = ?`, (err, stmt) => {

62
tasks/cli.py Normal file
View File

@@ -0,0 +1,62 @@
import argparse
from db import add_task, delete_task, init_db, list_tasks, update_task
def main():
init_db()
parser = argparse.ArgumentParser(description="Plexus Task Tracker")
subparsers = parser.add_subparsers(dest="command")
# Add task
add_parser = subparsers.add_parser("add", help="Add a new task")
add_parser.add_argument("title", help="Task title")
# List tasks
list_parser = subparsers.add_parser("list", help="List tasks")
list_parser.add_argument("--status", help="Filter by status")
# Update task
update_parser = subparsers.add_parser("update", help="Update task status")
update_parser.add_argument("id", type=int, help="Task ID")
update_parser.add_argument("status", help="New status")
# Done task
done_parser = subparsers.add_parser("done", help="Mark task as done")
done_parser.add_argument("id", type=int, help="Task ID")
# Delete task
delete_parser = subparsers.add_parser("delete", help="Delete a task")
delete_parser.add_argument("id", type=int, help="Task ID")
args = parser.parse_args()
if args.command == "add":
add_task(args.title)
print(f"Task added: {args.title}")
elif args.command == "list":
tasks = list_tasks(args.status)
print(f"{'ID':<5} {'Status':<15} {'Title'}")
print("-" * 40)
for t in tasks:
print(f"{t[0]:<5} {t[2]:<15} {t[1]}")
elif args.command == "update":
update_task(args.id, args.status)
print(f"Task {args.id} updated to {args.status}")
elif args.command == "done":
update_task(args.id, "done")
print(f"Task {args.id} marked as done")
elif args.command == "delete":
delete_task(args.id)
print(f"Task {args.id} deleted")
else:
parser.print_help()
if __name__ == "__main__":
main()

46
tasks/db.py Normal file
View File

@@ -0,0 +1,46 @@
import duckdb
DB_PATH = "tasks/tasks.duckdb"
def init_db():
con = duckdb.connect(DB_PATH)
con.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
title VARCHAR,
status VARCHAR DEFAULT 'todo',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
con.execute("CREATE SEQUENCE IF NOT EXISTS seq_task_id START 1")
con.close()
def get_connection():
return duckdb.connect(DB_PATH)
def add_task(title):
con = get_connection()
con.execute("INSERT INTO tasks (id, title) VALUES (nextval('seq_task_id'), ?)", [title])
con.close()
def list_tasks(status=None):
con = get_connection()
if status:
res = con.execute("SELECT * FROM tasks WHERE status = ? ORDER BY id", [status]).fetchall()
else:
res = con.execute("SELECT * FROM tasks ORDER BY id").fetchall()
con.close()
return res
def update_task(task_id, status):
con = get_connection()
con.execute("UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [status, task_id])
con.close()
def delete_task(task_id):
con = get_connection()
con.execute("DELETE FROM tasks WHERE id = ?", [task_id])
con.close()

BIN
tasks/tasks.duckdb Normal file

Binary file not shown.