feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
<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 = async (url, opts = {}) => {
|
||||
try {
|
||||
return await $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
auth.logout()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Redirect to pretty URL if using UID but username exists
|
||||
if (profile.value.user?.username && route.params.id === profile.value.userId) {
|
||||
navigateTo(`/profile/${profile.value.user.username}`, { replace: true })
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (auth.isAuthenticated.value) {
|
||||
loadProfile()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => auth.isAuthenticated.value, (isAuth) => {
|
||||
if (isAuth) loadProfile()
|
||||
})
|
||||
watch(profileId, loadProfile)
|
||||
</script>
|
||||
Reference in New Issue
Block a user