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,
}
}
}
})