335 lines
14 KiB
Vue
335 lines
14 KiB
Vue
<template>
|
|
<div class="min-h-screen bg-slate-950">
|
|
<div class="max-w-4xl mx-auto p-6">
|
|
<!-- Profile Header -->
|
|
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
|
|
<div class="flex items-start gap-4">
|
|
<img :src="profile.avatar" :alt="profile.displayName"
|
|
class="w-20 h-20 rounded-full bg-slate-800" />
|
|
<div class="flex-1">
|
|
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
|
|
<p class="text-slate-400 mt-1">{{ profile.bio }}</p>
|
|
<div class="flex items-center gap-4 mt-3 text-sm text-slate-500">
|
|
<span>Joined {{ formatDate(profile.joinedAt) }}</span>
|
|
<span>•</span>
|
|
<span>{{ profile.canvasCount }} canvases</span>
|
|
<span>•</span>
|
|
<span>{{ profile.followerCount }} followers</span>
|
|
<span>•</span>
|
|
<span>{{ profile.followingCount }} following</span>
|
|
</div>
|
|
</div>
|
|
<button v-if="!isOwnProfile" @click="toggleFollow"
|
|
:class="followButtonClass"
|
|
class="px-4 py-2 rounded-lg font-medium transition">
|
|
{{ isFollowing ? 'Following' : 'Follow' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
|
|
<button @click="activeTab = 'canvases'" :class="tabClass('canvases')" class="px-4 py-2 rounded text-sm font-medium transition">
|
|
Canvases
|
|
</button>
|
|
<button @click="activeTab = 'activity'" :class="tabClass('activity')" class="px-4 py-2 rounded text-sm font-medium transition">
|
|
Activity
|
|
</button>
|
|
<button v-if="isOwnProfile" @click="activeTab = 'groups'" :class="tabClass('groups')" class="px-4 py-2 rounded text-sm font-medium transition">
|
|
Groups
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Canvases Tab -->
|
|
<div v-if="activeTab === 'canvases'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div v-for="canvas in userCanvases" :key="canvas.id"
|
|
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer"
|
|
@click="openCanvas(canvas.id)">
|
|
<div class="flex items-start justify-between mb-3">
|
|
<div>
|
|
<h3 class="font-semibold text-slate-200 hover:text-blue-400 transition">{{ canvas.name }}</h3>
|
|
<p class="text-xs text-slate-500 mt-1">{{ canvas.nodeCount }} nodes • {{ formatDate(canvas.updatedAt) }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<span v-if="canvas.isPublic" class="text-xs px-2 py-1 bg-emerald-900/50 text-emerald-300 rounded">Public</span>
|
|
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Shared</span>
|
|
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Private</span>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-slate-400">
|
|
{{ formatDate(canvas.updatedAt) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Activity Tab -->
|
|
<div v-if="activeTab === 'activity'" class="space-y-3">
|
|
<div v-for="activity in userActivities" :key="activity.id"
|
|
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
|
|
<div class="flex-1">
|
|
<p class="text-sm text-slate-300">
|
|
<span class="font-medium">{{ formatActivity(activity.action) }}</span>
|
|
<span v-if="activity.targetType === 'canvas'" class="text-blue-400"> canvas</span>
|
|
<span v-if="activity.targetType === 'group'" class="text-emerald-400"> group</span>
|
|
<span v-if="activity.targetType === 'user'" class="text-purple-400"> user</span>
|
|
</p>
|
|
<p class="text-xs text-slate-500 mt-1">{{ formatDate(activity.timestamp) }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Groups Tab -->
|
|
<div v-if="activeTab === 'groups' && isOwnProfile" class="space-y-6">
|
|
|
|
<!-- My Groups -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider">Мои группы</h2>
|
|
<button @click="showCreateGroup = true" class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-medium">
|
|
+ Создать группу
|
|
</button>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div v-for="group in userGroups" :key="group.id"
|
|
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
|
<div class="flex items-start justify-between mb-2">
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
|
|
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
|
|
</div>
|
|
<span :class="group.membershipRole === 'admin' ? 'bg-purple-900/50 text-purple-300' : 'bg-blue-900/50 text-blue-300'"
|
|
class="text-xs px-2 py-1 rounded ml-2 shrink-0">{{ group.membershipRole }}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between mt-3">
|
|
<span class="text-xs text-slate-500">{{ group.isPublic ? '🌐 Публичная' : '🔒 Приватная' }}</span>
|
|
<button v-if="group.ownerId !== currentUserId" @click="leaveGroup(group.id)"
|
|
class="text-xs text-red-400 hover:text-red-300 transition">Выйти</button>
|
|
<button v-else @click="deleteGroup(group.id)"
|
|
class="text-xs text-red-400 hover:text-red-300 transition">Удалить</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm col-span-2 py-4 text-center">
|
|
Вы не состоите ни в одной группе
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- All Public Groups -->
|
|
<div>
|
|
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">Все публичные группы</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div v-for="group in availableGroups" :key="group.id"
|
|
class="bg-slate-900 border border-slate-700 rounded-xl p-4 flex items-center justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
|
|
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
|
|
</div>
|
|
<button v-if="!isMember(group.id)" @click="joinGroup(group.id)"
|
|
class="ml-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs shrink-0">Вступить</button>
|
|
<span v-else class="ml-3 text-xs text-slate-500 shrink-0">✓ Участник</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Group Modal -->
|
|
<div v-if="showCreateGroup" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showCreateGroup = false">
|
|
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md">
|
|
<h2 class="text-lg font-semibold text-slate-200 mb-4">Создать группу</h2>
|
|
<form @submit.prevent="createGroup" class="space-y-4">
|
|
<div>
|
|
<label class="text-sm text-slate-400 block mb-1">Название *</label>
|
|
<input v-model="newGroup.name" required class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Название группы" />
|
|
</div>
|
|
<div>
|
|
<label class="text-sm text-slate-400 block mb-1">Описание</label>
|
|
<input v-model="newGroup.description" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Краткое описание" />
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="rounded" />
|
|
<label for="isPublic" class="text-sm text-slate-300">Публичная (другие могут вступить)</label>
|
|
</div>
|
|
<div class="flex gap-3 pt-2">
|
|
<button type="submit" class="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium">Создать</button>
|
|
<button type="button" @click="showCreateGroup = false" class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg">Отмена</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
middleware: 'auth'
|
|
})
|
|
|
|
const auth = useAuth()
|
|
const route = useRoute()
|
|
const authFetch = (url, opts = {}) => $fetch(url, {
|
|
...opts,
|
|
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
|
})
|
|
|
|
const profileId = computed(() => route.params.id || auth.user.value?.id)
|
|
const currentUserId = computed(() => auth.user.value?.id || '')
|
|
const isOwnProfile = computed(() => profileId.value === currentUserId.value)
|
|
|
|
const profile = ref({
|
|
userId: profileId.value,
|
|
displayName: 'Loading...',
|
|
bio: '',
|
|
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${profileId.value}`,
|
|
joinedAt: new Date().toISOString(),
|
|
canvasCount: 0,
|
|
followerCount: 0,
|
|
followingCount: 0
|
|
})
|
|
|
|
const userCanvases = ref([])
|
|
const userActivities = ref([])
|
|
const userGroups = ref([])
|
|
const allGroups = ref([])
|
|
const isFollowing = ref(false)
|
|
const activeTab = ref('canvases')
|
|
const showCreateGroup = ref(false)
|
|
const newGroup = reactive({ name: '', description: '', isPublic: true })
|
|
|
|
const availableGroups = computed(() => allGroups.value.filter(g => g.isPublic))
|
|
const isMember = (groupId) => userGroups.value.some(g => g.id === groupId)
|
|
|
|
// Load profile data
|
|
const loadProfile = async () => {
|
|
try {
|
|
const [profileData, canvasesData, activitiesData] = await Promise.all([
|
|
authFetch(`/api/users/${profileId.value}/profile`),
|
|
authFetch(`/api/users/${profileId.value}/canvases`),
|
|
authFetch(`/api/users/${profileId.value}/activity`)
|
|
])
|
|
|
|
profile.value = profileData.profile
|
|
userCanvases.value = canvasesData.canvases
|
|
userActivities.value = activitiesData.activities
|
|
|
|
if (isOwnProfile.value) {
|
|
const [groupsData, allGroupsData] = await Promise.all([
|
|
authFetch('/api/user/groups'),
|
|
authFetch('/api/groups')
|
|
])
|
|
userGroups.value = groupsData.groups
|
|
allGroups.value = allGroupsData.groups
|
|
}
|
|
|
|
if (!isOwnProfile.value) {
|
|
try {
|
|
const followData = await authFetch(`/api/users/${profileId.value}/follow-status`)
|
|
isFollowing.value = followData.isFollowing
|
|
} catch {}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load profile:', error)
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
const openCanvas = (id) => {
|
|
navigateTo(`/canvas/${id}`)
|
|
}
|
|
|
|
const toggleFollow = async () => {
|
|
try {
|
|
const method = isFollowing.value ? 'DELETE' : 'POST'
|
|
await authFetch(`/api/users/${profileId.value}/follow`, { method })
|
|
isFollowing.value = !isFollowing.value
|
|
profile.value.followerCount += isFollowing.value ? 1 : -1
|
|
} catch (error) {
|
|
console.error('Failed to toggle follow:', error)
|
|
}
|
|
}
|
|
|
|
const createGroup = async () => {
|
|
try {
|
|
const res = await authFetch('/api/groups', { method: 'POST', body: { ...newGroup } })
|
|
userGroups.value.push({ ...res.group, membershipRole: 'admin' })
|
|
allGroups.value.push(res.group)
|
|
showCreateGroup.value = false
|
|
newGroup.name = ''
|
|
newGroup.description = ''
|
|
newGroup.isPublic = true
|
|
} catch (e) {
|
|
console.error('Failed to create group:', e)
|
|
}
|
|
}
|
|
|
|
const joinGroup = async (groupId) => {
|
|
try {
|
|
await authFetch(`/api/groups/${groupId}/join`, { method: 'POST' })
|
|
const group = allGroups.value.find(g => g.id === groupId)
|
|
if (group) userGroups.value.push({ ...group, membershipRole: 'member' })
|
|
} catch (e) {
|
|
console.error('Failed to join group:', e)
|
|
}
|
|
}
|
|
|
|
const leaveGroup = async (groupId) => {
|
|
if (!confirm('Выйти из группы?')) return
|
|
try {
|
|
await authFetch(`/api/groups/${groupId}/leave`, { method: 'POST' })
|
|
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
|
|
} catch (e) {
|
|
console.error('Failed to leave group:', e)
|
|
}
|
|
}
|
|
|
|
const deleteGroup = async (groupId) => {
|
|
if (!confirm('Удалить группу? Это действие необратимо.')) return
|
|
try {
|
|
await authFetch(`/api/groups/${groupId}`, { method: 'DELETE' })
|
|
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
|
|
allGroups.value = allGroups.value.filter(g => g.id !== groupId)
|
|
} catch (e) {
|
|
console.error('Failed to delete group:', e)
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
const formatDate = (date) => {
|
|
return new Date(date).toLocaleDateString('ru-RU', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
})
|
|
}
|
|
|
|
const formatActivity = (action) => {
|
|
const actions = {
|
|
created_canvas: 'created',
|
|
updated_canvas: 'updated',
|
|
joined_group: 'joined',
|
|
left_group: 'left',
|
|
followed_user: 'followed',
|
|
unfollowed_user: 'unfollowed'
|
|
}
|
|
return actions[action] || action
|
|
}
|
|
|
|
const tabClass = (tab) => {
|
|
return activeTab.value === tab
|
|
? 'bg-slate-800 text-slate-200'
|
|
: 'text-slate-400 hover:text-slate-300'
|
|
}
|
|
|
|
const followButtonClass = computed(() => {
|
|
return isFollowing.value
|
|
? 'bg-slate-700 hover:bg-slate-600 text-white'
|
|
: 'bg-blue-600 hover:bg-blue-500 text-white'
|
|
})
|
|
|
|
onMounted(loadProfile)
|
|
watch(profileId, loadProfile)
|
|
</script>
|