first commit
This commit is contained in:
48
client/src/App.vue
Normal file
48
client/src/App.vue
Normal 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>
|
||||
1
client/src/assets/vue.svg
Normal file
1
client/src/assets/vue.svg
Normal 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 |
156
client/src/components/ChatLayout.vue
Normal file
156
client/src/components/ChatLayout.vue
Normal 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>
|
||||
43
client/src/components/HelloWorld.vue
Normal file
43
client/src/components/HelloWorld.vue
Normal 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>
|
||||
186
client/src/components/MessageList.vue
Normal file
186
client/src/components/MessageList.vue
Normal 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>
|
||||
102
client/src/components/MusicPlayer.vue
Normal file
102
client/src/components/MusicPlayer.vue
Normal 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>
|
||||
142
client/src/components/TokenCreator.vue
Normal file
142
client/src/components/TokenCreator.vue
Normal 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>
|
||||
53
client/src/components/UserList.vue
Normal file
53
client/src/components/UserList.vue
Normal 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>
|
||||
60
client/src/components/WalletConnect.vue
Normal file
60
client/src/components/WalletConnect.vue
Normal 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
10
client/src/main.js
Normal 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
198
client/src/stores/chat.js
Normal 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
32
client/src/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user