first commit

This commit is contained in:
2026-01-13 22:55:46 +01:00
parent 3a3b0b046d
commit 2faf2dd8dc
31 changed files with 8490 additions and 0 deletions

14
client/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Build Stage
FROM node:22 as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production Stage
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

5
client/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

23
client/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /socket.io/ {
proxy_pass http://server:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /api/ {
proxy_pass http://server:3000;
}
}

3459
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
client/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@solana/web3.js": "^1.98.4",
"bs58": "^6.0.0",
"js-cookie": "^3.0.5",
"lucide-vue-next": "^0.562.0",
"pinia": "^3.0.4",
"socket.io-client": "^4.8.3",
"tweetnacl": "^1.0.3",
"vue": "^3.5.24"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.17",
"vite": "^7.2.4"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

48
client/src/App.vue Normal file
View File

@@ -0,0 +1,48 @@
<script setup>
import { ref } from 'vue';
import { useChatStore } from './stores/chat';
import WalletConnect from './components/WalletConnect.vue';
import ChatLayout from './components/ChatLayout.vue';
const chatStore = useChatStore();
const videoRef = ref(null);
const handleMuteToggle = (isMuted) => {
if (videoRef.value) {
// Note: YouTube iframe API would be needed for true control,
// but for a simple background video loop, we can't easily unmute a background iframe without user interaction policies.
// However, if we use a <video> tag it's easier.
// Let's try to use a direct video link or a YouTube embed with pointer-events-none.
}
};
</script>
<template>
<div class="relative h-screen w-screen overflow-hidden bg-crypto-dark text-white font-sans antialiased">
<!-- Background Video -->
<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none opacity-40">
<iframe
class="w-[300%] h-[300%] -translate-x-1/3 -translate-y-1/3"
src="https://www.youtube.com/embed/jfKfPfyJRdk?autoplay=1&mute=1&controls=0&loop=1&playlist=jfKfPfyJRdk&showinfo=0&modestbranding=1"
frameborder="0"
allow="autoplay; encrypted-media"
allowfullscreen
></iframe>
<!-- Overlay gradient -->
<div class="absolute inset-0 bg-crypto-dark/60 backdrop-blur-[2px]"></div>
</div>
<!-- Content -->
<div class="relative z-10 h-full">
<WalletConnect v-if="!chatStore.isConnected" />
<ChatLayout v-else @toggleMute="handleMuteToggle" />
</div>
</div>
</template>
<style>
/* Ensure iframe covers everything and doesn't capture clicks */
iframe {
pointer-events: none;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,156 @@
<script setup>
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import MessageList from './MessageList.vue';
import UserList from './UserList.vue';
import MusicPlayer from './MusicPlayer.vue';
import TokenCreator from './TokenCreator.vue';
import { Hash, Volume2, VolumeX, Settings, X, Coins } from 'lucide-vue-next';
import { ref } from 'vue';
const showTokenCreator = ref(false);
const chatStore = useChatStore();
const { channels, currentChannel, username, walletAddress } = storeToRefs(chatStore);
const isMuted = ref(true);
const showSettings = ref(false);
const newUsername = ref(username.value);
const emit = defineEmits(['toggleMute']);
const toggleMute = () => {
isMuted.value = !isMuted.value;
emit('toggleMute', isMuted.value);
};
const saveSettings = () => {
if (newUsername.value.trim()) {
chatStore.username = newUsername.value;
// In a real app, we would emit a socket event to update the DB
chatStore.socket.emit('join', { walletAddress: walletAddress.value, username: newUsername.value });
showSettings.value = false;
}
};
</script>
<template>
<div class="flex h-screen w-full overflow-hidden relative">
<!-- Settings Modal -->
<div v-if="showSettings" class="absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div class="bg-crypto-panel border border-white/10 rounded-2xl w-full max-w-md shadow-2xl animate-fade-in-up">
<div class="p-6 border-b border-white/5 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Profile Settings</h2>
<button @click="showSettings = false" class="text-gray-400 hover:text-white transition-colors">
<X size="24" />
</button>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-xs font-bold text-crypto-muted uppercase tracking-wider mb-2">Username</label>
<input
v-model="newUsername"
type="text"
class="w-full bg-crypto-dark border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all"
placeholder="Enter new username"
/>
</div>
<div>
<label class="block text-xs font-bold text-crypto-muted uppercase tracking-wider mb-2">Wallet Address</label>
<div class="w-full bg-crypto-dark/50 border border-white/5 rounded-xl px-4 py-3 text-gray-500 text-sm truncate">
{{ walletAddress }}
</div>
</div>
</div>
<div class="p-6 border-t border-white/5 flex gap-3">
<button
@click="showSettings = false"
class="flex-1 px-4 py-2.5 rounded-xl border border-white/10 text-white font-medium hover:bg-white/5 transition-all"
>
Cancel
</button>
<button
@click="saveSettings"
class="flex-1 px-4 py-2.5 rounded-xl bg-violet-600 text-white font-medium hover:bg-violet-500 shadow-lg shadow-violet-600/20 transition-all"
>
Save Changes
</button>
</div>
</div>
</div>
<!-- Channels Sidebar -->
<div class="w-64 bg-[#2b2d31] flex flex-col border-r border-black/20">
<div class="h-12 px-4 flex items-center justify-between border-b border-black/20 shadow-sm">
<h1 class="font-bold text-white truncate">Plexus Server</h1>
<button @click="toggleMute" class="text-gray-400 hover:text-gray-200 transition-colors">
<VolumeX v-if="isMuted" size="18" />
<Volume2 v-else size="18" />
</button>
</div>
<div class="flex-1 overflow-y-auto py-3 space-y-0.5 px-2">
<!-- Token Creator Link -->
<button
@click="showTokenCreator = true"
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group mb-4',
showTokenCreator ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
>
<Coins size="18" class="text-violet-400" />
<span class="text-sm font-medium">Token Creator</span>
</button>
<div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">Text Channels</div>
<div v-for="channel in channels" :key="channel.id">
<button
@click="chatStore.setChannel(channel.id); showTokenCreator = false"
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group',
currentChannel === channel.id && !showTokenCreator ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
>
<Hash size="18" :class="currentChannel === channel.id && !showTokenCreator ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'" />
<span class="text-sm font-medium">{{ channel.name }}</span>
</button>
</div>
</div>
<!-- Music Player & Profile -->
<div class="bg-[#232428] p-2 space-y-2">
<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="relative">
<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() }}
</div>
<div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-[#232428] rounded-full"></div>
</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-bold text-white truncate">{{ username }}</div>
<div class="text-[10px] text-gray-400 truncate">#{{ walletAddress?.slice(-4) }}</div>
</div>
<Settings size="14" class="text-gray-400 group-hover:text-gray-200" />
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 flex flex-col bg-[#313338] relative overflow-hidden">
<!-- Header -->
<div class="h-12 px-4 flex items-center border-b border-black/20 shadow-sm bg-[#313338]/95 backdrop-blur-sm z-10">
<Hash size="20" class="text-gray-400 mr-2" />
<span class="font-bold text-white mr-4">{{ showTokenCreator ? 'Token Creator' : currentChannel }}</span>
</div>
<div class="flex-1 flex overflow-hidden">
<div class="flex-1 flex flex-col relative overflow-hidden">
<TokenCreator v-if="showTokenCreator" @back="showTokenCreator = false" />
<MessageList v-else />
</div>
<!-- Member List (Discord Style) -->
<div class="w-60 bg-[#2b2d31] border-l border-black/20 hidden lg:flex flex-col">
<UserList />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<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

@@ -0,0 +1,186 @@
<script setup>
import { ref, onUpdated, nextTick } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { Send, Hash, Smile } from 'lucide-vue-next';
const chatStore = useChatStore();
const { currentMessages, currentChannel, walletAddress } = storeToRefs(chatStore);
const newMessage = ref('');
const messagesContainer = ref(null);
const showEmojiPicker = ref(null); // messageId
const EMOJIS = ['👍', '❤️', '🔥', '😂', '😮', '😢'];
const toggleReaction = (messageId, emoji) => {
chatStore.toggleReaction(messageId, emoji);
showEmojiPicker.value = null;
};
const getReactionCount = (reactions, emoji) => {
return reactions?.filter(r => r.emoji === emoji).length || 0;
};
const hasUserReacted = (reactions, emoji) => {
return reactions?.some(r => r.emoji === emoji && r.wallet_address === walletAddress.value);
};
const getUniqueEmojis = (reactions) => {
if (!reactions) return [];
return [...new Set(reactions.map(r => r.emoji))];
};
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
};
onUpdated(() => {
scrollToBottom();
});
const send = () => {
if (!newMessage.value.trim()) return;
chatStore.sendMessage(newMessage.value);
newMessage.value = '';
nextTick(scrollToBottom);
};
const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
</script>
<template>
<div class="flex-1 flex flex-col h-full bg-black/40 backdrop-blur-sm relative z-10">
<!-- Header -->
<div class="h-12 border-b border-white/5 flex items-center px-4 shadow-sm bg-crypto-panel/50">
<div class="text-lg font-bold text-white"># {{ currentChannel }}</div>
</div>
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-1 scroll-smooth">
<!-- Beginning of conversation marker -->
<div class="py-12 px-4 border-b border-white/5 mb-8">
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center text-white mb-4 shadow-xl shadow-violet-600/20">
<Hash size="32" />
</div>
<h2 class="text-2xl font-bold text-white mb-1">Welcome to #{{ currentChannel }}!</h2>
<p class="text-gray-400 text-sm max-w-md">This is the very beginning of the <span class="text-violet-400 font-semibold">#{{ currentChannel }}</span> channel. Use this space to connect, share, and grow with the community.</p>
</div>
<div v-for="(msg, index) in currentMessages" :key="msg.id"
class="group flex gap-4 px-4 py-1 hover:bg-white/[0.02] transition-colors relative"
>
<!-- Avatar (only if first message in group) -->
<div class="w-10 flex-shrink-0">
<div v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress"
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shadow-lg border border-white/10 mt-1"
:class="msg.walletAddress === walletAddress ? 'bg-gradient-to-br from-violet-500 to-fuchsia-600' : 'bg-crypto-panel'"
>
{{ msg.username.substring(0, 2).toUpperCase() }}
</div>
<div v-else class="w-10 text-[10px] text-crypto-muted opacity-0 group-hover:opacity-100 text-right pr-2 pt-1.5 transition-opacity">
{{ formatTime(msg.timestamp) }}
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress" class="flex items-baseline gap-2 mb-0.5">
<span :class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']">
{{ msg.username }}
</span>
<span v-if="msg.txId" class="text-[9px] text-crypto-muted font-mono bg-white/5 px-1.5 py-0.5 rounded border border-white/5">
{{ msg.txId.slice(0, 8) }}
</span>
<span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</span>
</div>
<div class="text-gray-100 text-sm leading-relaxed break-words">
{{ msg.content }}
</div>
<!-- Reactions Display -->
<div v-if="msg.reactions && msg.reactions.length > 0" class="flex flex-wrap gap-1 mt-1.5">
<button
v-for="emoji in getUniqueEmojis(msg.reactions)"
:key="emoji"
@click="toggleReaction(msg.id, emoji)"
:class="['flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-xs border transition-all animate-pop-in',
hasUserReacted(msg.reactions, emoji)
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10']"
>
<span>{{ emoji }}</span>
<span class="font-bold">{{ getReactionCount(msg.reactions, emoji) }}</span>
</button>
</div>
</div>
<!-- Hover Actions -->
<div class="absolute right-4 -top-4 opacity-0 group-hover:opacity-100 transition-opacity z-20 flex gap-1 bg-crypto-panel border border-white/10 rounded-lg p-1 shadow-xl">
<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"
title="Add Reaction"
>
<Smile size="16" />
</button>
<!-- Emoji Picker Popover -->
<div v-if="showEmojiPicker === msg.id" class="absolute right-0 bottom-full mb-2 bg-crypto-panel border border-white/10 rounded-xl p-2 shadow-2xl flex gap-1 z-30 animate-fade-in-up">
<button
v-for="emoji in EMOJIS"
:key="emoji"
@click="toggleReaction(msg.id, emoji)"
class="hover:scale-125 transition-transform p-1 text-lg"
>
{{ emoji }}
</button>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="p-4 bg-crypto-panel/80 border-t border-white/5">
<div class="relative">
<input
v-model="newMessage"
@keyup.enter="send"
type="text"
:placeholder="`Message #${currentChannel}`"
class="w-full bg-crypto-dark/50 text-white placeholder-gray-500 rounded-lg py-3 pl-4 pr-12 focus:outline-none focus:ring-2 focus:ring-crypto-accent/50 border border-white/5 transition-all"
/>
<button
@click="send"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-white hover:bg-white/10 rounded-md transition-colors"
>
<Send size="20" />
</button>
</div>
</div>
</div>
</template>
<style scoped>
.animate-pop-in {
animation: pop-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes pop-in {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.animate-fade-in-up {
animation: fade-in-up 0.2s ease-out;
}
@keyframes fade-in-up {
0% { transform: translateY(10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
</style>

View File

@@ -0,0 +1,102 @@
<script setup>
import { ref } from 'vue';
import { Play, Pause, SkipForward, Volume2, VolumeX, Music } from 'lucide-vue-next';
const isPlaying = ref(false);
const isMuted = ref(false);
const volume = ref(50);
// A few lofi streams/videos
const LOFI_VIDEOS = [
'jfKfPfyJRdk', // Lofi Girl - beats to relax/study to
'5yx6BWbLrqY', // Chillhop - lofi hip hop radio
'7NOSDKb0Hqh', // Coffee Shop Radio
];
const currentVideoIndex = ref(0);
const player = ref(null);
const togglePlay = () => {
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
player.value?.contentWindow.postMessage('{"event":"command","func":"playVideo","args":""}', '*');
} else {
player.value?.contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}', '*');
}
};
const nextTrack = () => {
currentVideoIndex.value = (currentVideoIndex.value + 1) % LOFI_VIDEOS.length;
};
const toggleMute = () => {
isMuted.value = !isMuted.value;
const vol = isMuted.value ? 0 : volume.value;
player.value?.contentWindow.postMessage(`{"event":"command","func":"setVolume","args":[${vol}]}`, '*');
};
const updateVolume = () => {
if (!isMuted.value) {
player.value?.contentWindow.postMessage(`{"event":"command","func":"setVolume","args":[${volume.value}]}`, '*');
}
};
</script>
<template>
<div class="p-4 bg-white/5 border border-white/10 rounded-2xl backdrop-blur-md shadow-xl">
<div class="flex items-center gap-4">
<!-- Hidden YouTube Player -->
<iframe
ref="player"
class="hidden"
:src="`https://www.youtube.com/embed/${LOFI_VIDEOS[currentVideoIndex]}?enablejsapi=1&autoplay=0&controls=0&disablekb=1&fs=0&modestbranding=1&iv_load_policy=3`"
frameborder="0"
></iframe>
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white shadow-lg animate-pulse-slow">
<Music size="24" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold text-white truncate">Lofi Radio</div>
<div class="text-[10px] text-crypto-muted uppercase tracking-wider">Chilling in the Nebula</div>
</div>
<div class="flex items-center gap-2">
<button @click="togglePlay" class="p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-all">
<Pause v-if="isPlaying" size="18" />
<Play v-else size="18" />
</button>
<button @click="nextTrack" class="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-white transition-all">
<SkipForward size="18" />
</button>
</div>
</div>
<div class="mt-4 flex items-center gap-3">
<button @click="toggleMute" class="text-gray-400 hover:text-white transition-colors">
<VolumeX v-if="isMuted || volume == 0" size="16" />
<Volume2 v-else size="16" />
</button>
<input
v-model="volume"
@input="updateVolume"
type="range"
min="0"
max="100"
class="flex-1 h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-indigo-500"
/>
</div>
</div>
</template>
<style scoped>
input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: #6366f1;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,142 @@
<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

@@ -0,0 +1,53 @@
<script setup>
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
const chatStore = useChatStore();
const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
</script>
<template>
<div class="w-60 bg-[#2b2d31] flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-y-auto p-3 space-y-6">
<!-- Online Users -->
<div v-if="onlineUsers.length > 0">
<h3 class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">Online {{ onlineUsers.length }}</h3>
<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 class="relative">
<div class="w-8 h-8 rounded-full bg-violet-600 flex items-center justify-center text-xs font-bold text-white shadow-sm">
{{ user.username.substring(0, 2).toUpperCase() }}
</div>
<div class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 border-2 border-[#2b2d31] rounded-full"></div>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-300 truncate group-hover:text-white transition-colors">
{{ user.username }}
</div>
</div>
</div>
</div>
</div>
<!-- Offline Users -->
<div v-if="offlineUsers.length > 0">
<h3 class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">Offline {{ offlineUsers.length }}</h3>
<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 class="relative">
<div class="w-8 h-8 rounded-full bg-[#3f4147] flex items-center justify-center text-xs font-bold text-gray-500">
{{ user.username.substring(0, 2).toUpperCase() }}
</div>
<div class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-gray-600 border-2 border-[#2b2d31] rounded-full"></div>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-500 truncate group-hover:text-gray-400">
{{ user.username }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useChatStore } from '../stores/chat';
const chatStore = useChatStore();
const isConnecting = ref(false);
const error = ref(null);
const connectWallet = async () => {
isConnecting.value = true;
error.value = null;
try {
const { solana } = window;
if (solana && solana.isPhantom) {
const response = await solana.connect();
const wallet = response.publicKey.toString();
// Sign message for authentication
const message = `Sign this message to login to Plexus: ${new Date().toDateString()}`;
const encodedMessage = new TextEncoder().encode(message);
const signedMessage = await solana.signMessage(encodedMessage, "utf8");
const signature = btoa(String.fromCharCode.apply(null, signedMessage.signature));
// Simple username generation or prompt
const username = wallet.slice(0, 4) + '...' + wallet.slice(-4);
chatStore.connect(wallet, username, signature);
} else {
alert('Solana object not found! Get a Phantom Wallet 👻');
window.open('https://phantom.app/', '_blank');
}
} catch (err) {
console.error(err);
error.value = "Connection failed or rejected";
} finally {
isConnecting.value = false;
}
};
</script>
<template>
<div class="flex flex-col items-center justify-center h-screen bg-black/50 backdrop-blur-sm">
<div class="p-8 bg-crypto-panel rounded-xl shadow-2xl border border-crypto-accent/20 text-center max-w-md w-full">
<h1 class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-400 to-pink-600 text-transparent bg-clip-text">Crypto Chat</h1>
<p class="text-crypto-muted mb-8">Connect your wallet to join the conversation.</p>
<button
@click="connectWallet"
:disabled="isConnecting"
class="w-full py-3 px-6 bg-crypto-accent hover:bg-violet-600 text-white rounded-lg font-semibold transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<span v-if="isConnecting">Connecting...</span>
<span v-else>Connect Phantom Wallet</span>
</button>
<p v-if="error" class="mt-4 text-red-400 text-sm">{{ error }}</p>
</div>
</div>
</template>

10
client/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')

198
client/src/stores/chat.js Normal file
View File

@@ -0,0 +1,198 @@
import { defineStore } from 'pinia';
import { io } from 'socket.io-client';
import { ref, computed, onMounted } from 'vue';
import Cookies from 'js-cookie';
export const useChatStore = defineStore('chat', () => {
const socket = ref(null);
const isConnected = ref(false);
const walletAddress = ref(null);
const username = ref(null);
const signature = ref(null);
const currentChannel = ref('nebula');
const messages = ref({}); // { channelId: [messages] }
const users = ref([]);
const channels = ref([]);
const onlineUsers = computed(() => users.value.filter(u => u.online));
const offlineUsers = computed(() => users.value.filter(u => !u.online));
const currentMessages = computed(() => messages.value[currentChannel.value] || []);
function connect(wallet, name, sig = null) {
walletAddress.value = wallet;
username.value = name;
if (sig) {
signature.value = sig;
Cookies.set('plexus_auth', JSON.stringify({ wallet, name, sig }), { expires: 7 });
}
// Connect to same origin (proxied by Vite in dev, Nginx in prod)
socket.value = io();
socket.value.on('connect', () => {
isConnected.value = true;
socket.value.emit('join', { walletAddress: wallet, username: name });
});
socket.value.on('disconnect', () => {
isConnected.value = false;
});
socket.value.on('newMessage', (message) => {
console.log('New message received:', message);
if (!messages.value[message.channelId]) {
messages.value[message.channelId] = [];
}
// Use spread to trigger reactivity if needed, though .push should work in Vue 3
messages.value[message.channelId] = [...messages.value[message.channelId], message];
});
socket.value.on('userList', (userList) => {
users.value = userList;
});
socket.value.on('updateReactions', ({ messageId, reactions }) => {
for (const channelId in messages.value) {
const msg = messages.value[channelId].find(m => m.id === messageId);
if (msg) {
msg.reactions = reactions;
break;
}
}
});
fetchChannels();
fetchMessages(currentChannel.value);
}
function toggleReaction(messageId, emoji) {
if (!socket.value) return;
// Simulate a blockchain transaction for reaction
console.log('Simulating 1 $PLEXUS transaction for reaction...');
socket.value.emit('toggleReaction', {
messageId,
walletAddress: walletAddress.value,
emoji
});
}
onMounted(() => {
const savedAuth = Cookies.get('plexus_auth');
if (savedAuth) {
try {
const { wallet, name, sig } = JSON.parse(savedAuth);
connect(wallet, name, sig);
} catch (e) {
console.error('Failed to parse saved auth', e);
}
}
});
function sendMessage(content) {
if (!socket.value || !content.trim()) return;
// Simulate a blockchain transaction
console.log('Simulating 1 $PLEXUS transaction for message...');
const mockTxId = 'TX' + Math.random().toString(36).substring(2, 15).toUpperCase();
socket.value.emit('sendMessage', {
channelId: currentChannel.value,
walletAddress: walletAddress.value,
content,
txId: mockTxId
});
}
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
});
}
function updateUsername(newUsername) {
if (!socket.value || !newUsername.trim()) return;
// Simulate 30 $PLEXUS transaction for username change
console.log('Simulating 30 $PLEXUS transaction for username change...');
const mockTxId = 'TX_NAME_' + Math.random().toString(36).substring(2, 15).toUpperCase();
socket.value.emit('updateUsername', {
walletAddress: walletAddress.value,
newUsername,
txId: mockTxId
});
}
// Listen for username updates
onMounted(() => {
if (socket.value) {
socket.value.on('usernameUpdated', ({ username: newName }) => {
username.value = newName;
// Update cookie
const authData = JSON.parse(Cookies.get('plexus_auth') || '{}');
authData.name = newName;
Cookies.set('plexus_auth', JSON.stringify(authData), { expires: 7 });
});
socket.value.on('error', (err) => {
alert(err.message || 'An error occurred');
});
}
});
function setChannel(channelId) {
currentChannel.value = channelId;
if (!messages.value[channelId]) {
fetchMessages(channelId);
}
}
async function fetchChannels() {
try {
const res = await fetch('/api/channels');
channels.value = await res.json();
} catch (e) {
console.error('Failed to fetch channels', e);
}
}
async function fetchMessages(channelId) {
try {
const res = await fetch(`/api/messages/${channelId}`);
const data = await res.json();
messages.value[channelId] = data;
} catch (e) {
console.error('Failed to fetch messages', e);
}
}
return {
socket,
isConnected,
walletAddress,
username,
currentChannel,
messages,
users,
channels,
onlineUsers,
offlineUsers,
currentMessages,
connect,
sendMessage,
toggleReaction,
updateUsername,
setChannel
};
});

32
client/src/style.css Normal file
View File

@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-crypto-dark text-crypto-text overflow-hidden;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
@apply bg-crypto-dark;
}
::-webkit-scrollbar-thumb {
@apply bg-crypto-panel rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-crypto-muted;
}
.glass {
@apply backdrop-blur-md bg-white/5 border border-white/10;
}
.glass-panel {
@apply backdrop-blur-xl bg-black/40 border-r border-white/5;
}

32
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,32 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'crypto-dark': '#0f172a',
'crypto-panel': '#1e293b',
'crypto-accent': '#8b5cf6', // Violet
'crypto-text': '#e2e8f0',
'crypto-muted': '#94a3b8',
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
animation: {
'fade-in-up': 'fadeInUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
}
}
},
},
plugins: [],
}

20
client/vite.config.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
changeOrigin: true,
}
}
}
})

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
server:
build: ./server
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
- PORT=3000
client:
build: ./client
ports:
- "8080:80"
depends_on:
- server

15
server/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Create data directory
RUN mkdir -p data
EXPOSE 3000
CMD ["node", "index.js"]

56
server/db.js Normal file
View File

@@ -0,0 +1,56 @@
const duckdb = require('duckdb');
const path = require('path');
const dbPath = path.join(__dirname, 'data', 'chat.duckdb');
const db = new duckdb.Database(dbPath);
const con = db.connect();
// Initialize Schema
con.exec(`
CREATE TABLE IF NOT EXISTS users (
wallet_address VARCHAR PRIMARY KEY,
username VARCHAR UNIQUE,
last_seen TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
channel_id VARCHAR,
wallet_address VARCHAR,
content VARCHAR,
timestamp TIMESTAMP,
tx_id VARCHAR,
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE TABLE IF NOT EXISTS reactions (
message_id INTEGER,
wallet_address VARCHAR,
emoji VARCHAR,
PRIMARY KEY (message_id, wallet_address, emoji),
FOREIGN KEY (wallet_address) REFERENCES users(wallet_address)
);
CREATE SEQUENCE IF NOT EXISTS seq_msg_id START 1;
-- Migration: Add tx_id to messages if it doesn't exist (for existing DBs)
PRAGMA table_info('messages');
`, (err) => {
if (err) return console.error('Schema error:', err);
// Check if tx_id exists, if not add it
con.all("PRAGMA table_info('messages')", (err, rows) => {
if (err) return;
const hasTxId = rows.some(r => r.name === 'tx_id');
if (!hasTxId) {
con.run("ALTER TABLE messages ADD COLUMN tx_id VARCHAR", (err) => {
if (err) console.error("Error adding tx_id column:", err);
else console.log("Added tx_id column to messages table");
});
}
});
console.log('Database initialized and cleared');
});
module.exports = { db, con };

286
server/index.js Normal file
View File

@@ -0,0 +1,286 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const cors = require('cors');
const { db, con } = require('./db');
const app = express();
app.use(cors());
app.use(express.json());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*", // Allow all for dev, restrict in prod
methods: ["GET", "POST"]
}
});
const CHANNELS = [
{ id: 'nebula', name: 'Nebula' },
{ id: 'solstice', name: 'Solstice' },
{ id: 'zenith', name: 'Zenith' },
{ id: 'aether', name: 'Aether' },
{ id: 'vortex', name: 'Vortex' },
{ id: 'borealis', name: 'Borealis' },
{ id: 'chronos', name: 'Chronos' },
{ id: 'elysium', name: 'Elysium' },
{ id: 'ignis', name: 'Ignis' },
{ id: 'nova', name: 'Nova' }
];
// Store connected users in memory for "Online" status
// Map<socketId, walletAddress>
const connectedSockets = new Map();
io.on('connection', (socket) => {
console.log('A user connected:', socket.id);
socket.on('join', async ({ walletAddress, username }) => {
console.log(`User joining: ${username} (${walletAddress})`);
connectedSockets.set(socket.id, walletAddress);
const now = new Date().toISOString();
// First, check if this wallet already has a username
con.prepare(`SELECT username FROM users WHERE wallet_address = ?`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(walletAddress, (err, rows) => {
stmt.finalize();
if (err) return console.error("Execute error:", err);
if (rows.length > 0) {
// User exists, update last seen
con.prepare(`UPDATE users SET last_seen = ? WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return console.error("Prepare error:", err);
uStmt.run(now, walletAddress, (err) => {
uStmt.finalize();
if (err) console.error("Update error:", err);
broadcastUserList();
});
});
} else {
// New user, check if username is taken
con.prepare(`SELECT wallet_address FROM users WHERE username = ?`, (err, nStmt) => {
if (err) return console.error("Prepare error:", err);
nStmt.all(username, (err, uRows) => {
nStmt.finalize();
if (err) return console.error("Check error:", err);
let finalUsername = username;
if (uRows.length > 0) {
finalUsername = `${username}_${walletAddress.slice(0, 4)}`;
}
con.prepare(`INSERT INTO users (wallet_address, username, last_seen) VALUES (?, ?, ?)`, (err, iStmt) => {
if (err) return console.error("Prepare error:", err);
iStmt.run(walletAddress, finalUsername, now, (err) => {
iStmt.finalize();
if (err) console.error("Insert error:", err);
broadcastUserList();
});
});
});
});
}
});
});
});
socket.on('updateUsername', ({ walletAddress, newUsername, txId }) => {
console.log(`User ${walletAddress} requesting username change to ${newUsername} (TX: ${txId})`);
// Check if username is taken
con.prepare(`SELECT wallet_address FROM users WHERE username = ?`, (err, stmt) => {
if (err) return socket.emit('error', { message: 'Database error' });
stmt.all(newUsername, (err, rows) => {
stmt.finalize();
if (err) return socket.emit('error', { message: 'Database error' });
if (rows.length > 0) {
return socket.emit('error', { message: 'Username already taken' });
}
// Update username
con.prepare(`UPDATE users SET username = ? WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return socket.emit('error', { message: 'Failed to update username' });
uStmt.run(newUsername, walletAddress, (err) => {
uStmt.finalize();
if (err) return socket.emit('error', { message: 'Failed to update username' });
console.log(`Username updated for ${walletAddress} to ${newUsername}`);
socket.emit('usernameUpdated', { username: newUsername });
broadcastUserList();
// Also broadcast a system message about the change
const systemMsg = {
id: Date.now(),
channelId: 'nebula',
walletAddress: 'system',
username: 'System',
content: `${walletAddress.slice(0, 4)}... changed their name to ${newUsername}`,
timestamp: new Date().toISOString()
};
io.emit('newMessage', systemMsg);
});
});
});
});
});
socket.on('sendMessage', ({ channelId, walletAddress, content, txId }) => {
if (!content || content.trim() === '') return;
console.log(`Message from ${walletAddress} in ${channelId} (TX: ${txId}): ${content}`);
const timestamp = new Date().toISOString();
con.prepare(`INSERT INTO messages (id, channel_id, wallet_address, content, timestamp, tx_id)
VALUES (nextval('seq_msg_id'), ?, ?, ?, ?, ?) RETURNING id`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(channelId, walletAddress, content, timestamp, txId, (err, rows) => {
stmt.finalize();
if (err) {
console.error("Error saving message:", err);
return;
}
const msgId = rows[0].id;
con.prepare(`SELECT username FROM users WHERE wallet_address = ?`, (err, uStmt) => {
if (err) return console.error("Prepare error:", err);
uStmt.all(walletAddress, (err, uRows) => {
uStmt.finalize();
const username = uRows.length > 0 ? uRows[0].username : walletAddress.slice(0, 4) + '...';
const message = {
id: msgId,
channelId,
walletAddress,
username,
content,
timestamp,
txId
};
io.emit('newMessage', message);
});
});
});
});
});
socket.on('toggleReaction', ({ messageId, walletAddress, emoji }) => {
console.log(`Toggling reaction: ${emoji} on message ${messageId} by ${walletAddress}`);
con.prepare(`SELECT * FROM reactions WHERE message_id = ? AND wallet_address = ? AND emoji = ?`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(messageId, walletAddress, emoji, (err, rows) => {
stmt.finalize();
if (err) return console.error("Error checking reaction:", err);
if (rows.length > 0) {
con.prepare(`DELETE FROM reactions WHERE message_id = ? AND wallet_address = ? AND emoji = ?`, (err, dStmt) => {
if (err) return console.error("Prepare error:", err);
dStmt.run(messageId, walletAddress, emoji, (err) => {
dStmt.finalize();
if (err) return console.error("Error removing reaction:", err);
broadcastReactions(messageId);
});
});
} else {
con.prepare(`INSERT INTO reactions (message_id, wallet_address, emoji) VALUES (?, ?, ?)`, (err, iStmt) => {
if (err) return console.error("Prepare error:", err);
iStmt.run(messageId, walletAddress, emoji, (err) => {
iStmt.finalize();
if (err) return console.error("Error adding reaction:", err);
broadcastReactions(messageId);
});
});
}
});
});
});
function broadcastReactions(messageId) {
console.log("Broadcasting reactions for message:", messageId);
con.prepare(`SELECT emoji, wallet_address FROM reactions WHERE message_id = ?`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all(messageId, (err, rows) => {
stmt.finalize();
if (err) {
console.error("Error fetching reactions for broadcast:", err);
return;
}
console.log(`Found ${rows.length} reactions, emitting updateReactions`);
io.emit('updateReactions', { messageId, reactions: rows });
});
});
}
socket.on('disconnect', () => {
const walletAddress = connectedSockets.get(socket.id);
connectedSockets.delete(socket.id);
if (walletAddress) {
// Update last seen?
broadcastUserList();
}
console.log('User disconnected:', socket.id);
});
function broadcastUserList() {
// Get all users from DB to show offline ones too
con.prepare(`SELECT wallet_address, username, last_seen FROM users`, (err, stmt) => {
if (err) return console.error("Prepare error:", err);
stmt.all((err, rows) => {
stmt.finalize();
if (err) return;
const onlineWallets = new Set(connectedSockets.values());
const users = rows.map(u => ({
...u,
online: onlineWallets.has(u.wallet_address)
}));
io.emit('userList', users);
});
});
}
});
app.get('/api/channels', (req, res) => {
res.json(CHANNELS);
});
app.get('/api/messages/:channelId', (req, res) => {
const { channelId } = req.params;
con.prepare(`
SELECT m.*, u.username
FROM messages m
LEFT JOIN users u ON m.wallet_address = u.wallet_address
WHERE m.channel_id = ?
ORDER BY m.timestamp ASC
LIMIT 100
`, (err, stmt) => {
if (err) return res.status(500).json({ error: err.message });
stmt.all(channelId, (err, messages) => {
stmt.finalize();
if (err) return res.status(500).json({ error: err.message });
// Fetch reactions for all these messages
con.prepare(`SELECT * FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE channel_id = ?)`, (err, rStmt) => {
if (err) return res.json(messages.map(m => ({ ...m, reactions: [] })));
rStmt.all(channelId, (err, reactions) => {
rStmt.finalize();
if (err) return res.json(messages.map(m => ({ ...m, reactions: [] })));
const messagesWithReactions = messages.map(m => ({
...m,
reactions: reactions.filter(r => r.message_id === m.id)
}));
res.json(messagesWithReactions);
});
});
});
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

5
server/inspect-db.js Normal file
View File

@@ -0,0 +1,5 @@
const duckdb = require('duckdb');
const db = new duckdb.Database(':memory:');
const con = db.connect();
console.log('Connection methods:', Object.keys(Object.getPrototypeOf(con)));
process.exit(0);

3334
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
server/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "server",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "mocha tests/**/*.test.js --exit"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bs58": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"duckdb": "^1.4.3",
"express": "^5.2.1",
"socket.io": "^4.8.3",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"chai": "^6.2.2",
"mocha": "^11.7.5",
"socket.io-client": "^4.8.3"
}
}

47
server/test-db.js Normal file
View File

@@ -0,0 +1,47 @@
const duckdb = require('duckdb');
const path = require('path');
const dbPath = path.join(__dirname, 'data', 'chat.duckdb');
const db = new duckdb.Database(dbPath);
const con = db.connect();
console.log('Testing DuckDB operations...');
const walletAddress = 'test_wallet_' + Date.now();
const username = 'TestUser';
const now = new Date().toISOString();
// Test User Upsert
con.run(`INSERT INTO users (wallet_address, username, last_seen) VALUES (?, ?, ?)
ON CONFLICT (wallet_address) DO UPDATE SET last_seen = EXCLUDED.last_seen, username = EXCLUDED.username`,
[walletAddress, username, now], (err) => {
if (err) {
console.error('FAIL: User upsert failed:', err);
process.exit(1);
}
console.log('PASS: User upsert successful');
// Test Message Insert
const channelId = 'nebula';
const content = 'Test message content';
con.run(`INSERT INTO messages (id, channel_id, wallet_address, content, timestamp)
VALUES (nextval('seq_msg_id'), ?, ?, ?, ?)`,
[channelId, walletAddress, content, now], function (err) {
if (err) {
console.error('FAIL: Message insert failed:', err);
process.exit(1);
}
console.log('PASS: Message insert successful');
// Verify Data
con.all(`SELECT * FROM messages WHERE wallet_address = ?`, [walletAddress], (err, rows) => {
if (err || rows.length === 0) {
console.error('FAIL: Verification failed:', err);
process.exit(1);
}
console.log('PASS: Verification successful, found message:', rows[0].content);
console.log('All tests passed!');
process.exit(0);
});
});
});

View File

@@ -0,0 +1,70 @@
const io = require('socket.io-client');
const assert = require('chai').assert;
describe('Plexus Socket Integration Tests', function () {
this.timeout(5000);
let client;
before((done) => {
client = io('http://localhost:3000');
client.on('connect', done);
});
after(() => {
client.disconnect();
});
it('should join and broadcast user list', (done) => {
client.emit('join', { walletAddress: 'test_wallet', username: 'TestUser' });
client.on('userList', (users) => {
const user = users.find(u => u.wallet_address === 'test_wallet');
assert.exists(user);
assert.equal(user.username, 'TestUser');
done();
});
});
it('should send and receive messages', (done) => {
const msgData = {
channelId: 'nebula',
walletAddress: 'test_wallet',
content: 'Hello World',
txId: 'TX123'
};
client.emit('sendMessage', msgData);
client.on('newMessage', (msg) => {
if (msg.content === 'Hello World') {
assert.equal(msg.walletAddress, 'test_wallet');
assert.equal(msg.txId, 'TX123');
done();
}
});
});
it('should toggle reactions', (done) => {
const msgData = {
channelId: 'nebula',
walletAddress: 'test_wallet',
content: 'Reaction Test',
txId: 'TX_REACT'
};
// Set up listener first
client.once('newMessage', (msg) => {
const messageId = msg.id;
console.log('Received newMessage with ID:', messageId, 'type:', typeof messageId);
client.emit('toggleReaction', { messageId, walletAddress: 'test_wallet', emoji: '👍' });
client.on('updateReactions', (data) => {
console.log('Received updateReactions for message:', data.messageId, 'type:', typeof data.messageId);
if (String(data.messageId) === String(messageId)) {
assert.exists(data.reactions);
done();
}
});
});
client.emit('sendMessage', msgData);
});
});