feat: implement user profiles and social walls (Tasks #171, #173, #174)

This commit is contained in:
2026-01-13 23:24:19 +01:00
parent 477f447b67
commit ed62ac0641
8 changed files with 372 additions and 20 deletions

View File

@@ -3,12 +3,13 @@ import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import MessageList from './MessageList.vue';
import UserList from './UserList.vue';
import UserList from './UserList.vue';
import MusicPlayer from './MusicPlayer.vue';
import TokenCreator from './TokenCreator.vue';
import { Hash, Volume2, VolumeX, Settings, X, Coins, Menu } from 'lucide-vue-next';
import { Hash, Volume2, VolumeX, Settings, X, Coins, Menu, User } from 'lucide-vue-next';
import { ref } from 'vue';
const showTokenCreator = ref(false);
const showProfile = ref(false);
const selectedProfileAddress = ref(null);
const showMobileMenu = ref(false);
const chatStore = useChatStore();
@@ -102,24 +103,24 @@ const saveSettings = () => {
</div>
<div class="flex-1 overflow-y-auto py-3 space-y-0.5 px-2">
<!-- Token Creator Link -->
<!-- Profile Link -->
<button
@click="showTokenCreator = true; showMobileMenu = false"
@click="selectedProfileAddress = walletAddress; showProfile = true; showMobileMenu = false"
:class="['w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-all group mb-4',
showTokenCreator ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
showProfile && selectedProfileAddress === walletAddress ? 'bg-[#3f4147] text-white' : 'text-gray-400 hover:bg-[#35373c] hover:text-gray-200']"
>
<Coins size="18" class="text-violet-400" />
<span class="text-sm font-medium">Token Creator</span>
<User size="18" class="text-violet-400" />
<span class="text-sm font-medium">My Profile</span>
</button>
<div class="px-2 mb-2 text-[11px] font-bold text-gray-500 uppercase tracking-wider">Text Channels</div>
<div v-for="channel in channels" :key="channel.id">
<button
@click="chatStore.setChannel(channel.id); showTokenCreator = false; showMobileMenu = false"
@click="chatStore.setChannel(channel.id); showProfile = false; showMobileMenu = 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']"
currentChannel === channel.id && !showProfile ? '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'" />
<Hash size="18" :class="currentChannel === channel.id && !showProfile ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'" />
<span class="text-sm font-medium">{{ channel.name }}</span>
</button>
</div>
@@ -156,18 +157,18 @@ const saveSettings = () => {
<Menu size="24" />
</button>
<Hash size="20" class="text-gray-400 mr-2" />
<span class="font-bold text-white mr-4">{{ showTokenCreator ? 'Token Creator' : currentChannel }}</span>
<span class="font-bold text-white mr-4">{{ showProfile ? (selectedProfileAddress === walletAddress ? 'My Profile' : 'User Profile') : 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 />
<UserProfile v-if="showProfile" :address="selectedProfileAddress" />
<MessageList v-else @view-profile="(addr) => { selectedProfileAddress = addr; showProfile = true; }" />
</div>
<!-- Member List (Discord Style) -->
<div class="w-60 bg-discord-sidebar border-l border-black/20 hidden xl:flex flex-col">
<UserList />
<UserList @view-profile="(addr) => { selectedProfileAddress = addr; showProfile = true; }" />
</div>
</div>
</div>

View File

@@ -50,6 +50,8 @@ const send = () => {
const formatTime = (isoString) => {
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const emit = defineEmits(['view-profile']);
</script>
<template>
@@ -79,7 +81,8 @@ const formatTime = (isoString) => {
<!-- 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"
@click="emit('view-profile', 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 cursor-pointer hover:opacity-80 transition-opacity"
:class="msg.walletAddress === walletAddress ? 'bg-gradient-to-br from-violet-500 to-fuchsia-600' : 'bg-discord-sidebar'"
>
{{ msg.username?.substring(0, 2).toUpperCase() }}
@@ -92,7 +95,10 @@ const formatTime = (isoString) => {
<!-- Content -->
<div class="flex-1 min-w-0">
<div v-if="index === 0 || currentMessages[index-1].walletAddress !== msg.walletAddress" class="flex items-center gap-2 mb-0.5">
<span :class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']">
<span
@click="emit('view-profile', msg.walletAddress)"
:class="['text-sm font-bold hover:underline cursor-pointer', msg.walletAddress === walletAddress ? 'text-violet-400' : 'text-white']"
>
{{ msg.username }}
</span>
<span class="text-[10px] text-crypto-muted">{{ formatTime(msg.timestamp) }}</span>

View File

@@ -4,6 +4,8 @@ import { storeToRefs } from 'pinia';
const chatStore = useChatStore();
const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
const emit = defineEmits(['view-profile']);
</script>
<template>
@@ -13,7 +15,12 @@ const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
<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
v-for="user in onlineUsers"
:key="user.wallet_address"
@click="emit('view-profile', 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() }}
@@ -33,7 +40,12 @@ const { onlineUsers, offlineUsers } = storeToRefs(chatStore);
<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
v-for="user in offlineUsers"
:key="user.wallet_address"
@click="emit('view-profile', 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() }}

View File

@@ -0,0 +1,201 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useChatStore } from '../stores/chat';
import { storeToRefs } from 'pinia';
import { User, MessageSquare, Calendar, MapPin, Link as LinkIcon, Edit3, Send, X } from 'lucide-vue-next';
const props = defineProps({
address: {
type: String,
required: true
}
});
const chatStore = useChatStore();
const { profileUser, profilePosts, isProfileLoading, walletAddress, username } = storeToRefs(chatStore);
const isEditing = ref(false);
const editBio = ref('');
const editBannerColor = ref('');
const newPostContent = ref('');
const loadProfile = () => {
chatStore.getProfile(props.address);
};
onMounted(loadProfile);
watch(() => props.address, loadProfile);
const startEditing = () => {
editBio.value = profileUser.value.bio || '';
editBannerColor.value = profileUser.value.banner_color || '#6366f1';
isEditing.value = true;
};
const saveProfile = () => {
chatStore.updateProfile(editBio.value, editBannerColor.value);
isEditing.value = false;
};
const submitPost = () => {
if (!newPostContent.value.trim()) return;
chatStore.createPost(newPostContent.value);
newPostContent.value = '';
};
const formatTime = (isoString) => {
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
</script>
<template>
<div class="flex-1 flex flex-col h-full bg-discord-dark overflow-y-auto custom-scrollbar">
<div v-if="isProfileLoading" class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-violet-500"></div>
</div>
<div v-else-if="profileUser" class="flex flex-col">
<!-- Banner -->
<div
class="h-48 w-full relative transition-colors duration-500"
:style="{ backgroundColor: profileUser.banner_color }"
>
<div class="absolute -bottom-16 left-8">
<div class="w-32 h-32 rounded-full border-8 border-discord-dark bg-violet-600 flex items-center justify-center text-white text-4xl font-bold shadow-xl">
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
</div>
</div>
</div>
<!-- Profile Info -->
<div class="mt-20 px-8 pb-8 border-b border-white/5">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white">{{ profileUser.username }}</h1>
<p class="text-gray-400 font-mono text-sm mt-1">{{ profileUser.wallet_address }}</p>
</div>
<button
v-if="profileUser.wallet_address === walletAddress"
@click="startEditing"
class="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-white text-sm font-medium transition-all flex items-center gap-2"
>
<Edit3 size="16" /> Edit Profile
</button>
</div>
<div class="mt-4 text-gray-200 text-lg leading-relaxed max-w-2xl">
{{ profileUser.bio || 'No bio yet...' }}
</div>
<div class="mt-6 flex flex-wrap gap-4 text-gray-400 text-sm">
<div class="flex items-center gap-1.5">
<Calendar size="16" /> Joined {{ new Date(profileUser.last_seen).toLocaleDateString() }}
</div>
</div>
</div>
<!-- Wall / Posts -->
<div class="p-8 max-w-3xl">
<h2 class="text-xl font-bold text-white mb-6 flex items-center gap-2">
<MessageSquare size="20" class="text-violet-400" /> Wall
</h2>
<!-- New Post Input (only for owner) -->
<div v-if="profileUser.wallet_address === walletAddress" class="mb-8 bg-discord-sidebar/30 rounded-2xl p-4 border border-white/5">
<textarea
v-model="newPostContent"
placeholder="What's on your mind?"
class="w-full bg-transparent text-white placeholder-gray-500 resize-none focus:outline-none min-h-[100px]"
></textarea>
<div class="flex justify-end mt-2 pt-2 border-t border-white/5">
<button
@click="submitPost"
class="px-6 py-2 bg-violet-600 hover:bg-violet-500 text-white rounded-full font-bold transition-all flex items-center gap-2 shadow-lg shadow-violet-600/20"
>
Post <Send size="16" />
</button>
</div>
</div>
<!-- Posts List -->
<div class="space-y-6">
<div v-if="profilePosts.length === 0" class="text-center py-12 text-gray-500 italic">
No posts yet.
</div>
<div
v-for="post in profilePosts"
:key="post.id"
class="bg-discord-sidebar/20 rounded-2xl p-6 border border-white/5 hover:border-white/10 transition-all group"
>
<div class="flex justify-between items-start mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-violet-600/20 flex items-center justify-center text-violet-400 font-bold">
{{ profileUser.username?.substring(0, 2).toUpperCase() }}
</div>
<div>
<div class="font-bold text-white">{{ profileUser.username }}</div>
<div class="text-xs text-gray-500">{{ formatTime(post.timestamp) }}</div>
</div>
</div>
</div>
<div class="text-gray-200 leading-relaxed">
{{ post.content }}
</div>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div v-if="isEditing" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div class="bg-discord-sidebar border border-white/10 rounded-2xl w-full max-w-lg shadow-2xl animate-pop-in">
<div class="p-6 border-b border-white/5 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Edit Profile</h2>
<button @click="isEditing = false" class="text-gray-400 hover:text-white transition-colors">
<X size="24" />
</button>
</div>
<div class="p-6 space-y-6">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bio</label>
<textarea
v-model="editBio"
class="w-full bg-discord-black border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50 transition-all min-h-[120px]"
placeholder="Tell us about yourself..."
></textarea>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Banner Color</label>
<div class="flex gap-3">
<input
v-model="editBannerColor"
type="color"
class="w-12 h-12 rounded-lg bg-transparent border-none cursor-pointer"
/>
<input
v-model="editBannerColor"
type="text"
class="flex-1 bg-discord-black border border-white/10 rounded-xl px-4 py-3 text-white font-mono"
/>
</div>
</div>
</div>
<div class="p-6 border-t border-white/5 flex gap-3">
<button
@click="isEditing = 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="saveProfile"
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>
</div>
</template>