feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="max-w-xl mx-auto p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Настройки</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Profile Settings -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>👤</span> Личный профиль
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<img :src="profileForm.avatar" class="w-16 h-16 rounded-full bg-slate-700 object-cover border-2 border-blue-500/30" />
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Ссылка на аватар</label>
|
||||
<input v-model="profileForm.avatar" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Имя (Display Name)</label>
|
||||
<input v-model="profileForm.displayName" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">О себе (Bio)</label>
|
||||
<textarea v-model="profileForm.bio" rows="3" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="saveProfile"
|
||||
:disabled="saving"
|
||||
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{{ saving ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
<p v-if="saveMessage" :class="saveMessage.type === 'error' ? 'text-red-400' : 'text-emerald-400'" class="text-center text-xs mt-2">
|
||||
{{ saveMessage.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Groups -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>👥</span> Мои Группы
|
||||
</h2>
|
||||
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm py-4">
|
||||
Вы не состоите ни в одной группе.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="g in userGroups" :key="g.id" class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-slate-700">
|
||||
<div>
|
||||
<div class="font-medium text-slate-200">{{ g.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ g.description }}</div>
|
||||
</div>
|
||||
<span :class="g.membershipRole === 'admin' ? 'text-purple-400 bg-purple-900/20' : 'text-blue-400 bg-blue-900/20'" class="text-[10px] uppercase font-bold px-2 py-0.5 rounded border border-current/20">
|
||||
{{ g.membershipRole }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push Notifications -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>🔔</span> Push-уведомления
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Статус поддержки</span>
|
||||
<span :class="isSupported ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ isSupported ? 'Доступно' : 'Недоступно' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Разрешение</span>
|
||||
<span :class="permissionGranted ? 'text-emerald-400' : 'text-yellow-400'">
|
||||
{{ permissionGranted ? 'Разрешено' : 'Не запрошено' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isSupported && !permissionGranted"
|
||||
@click="enablePush"
|
||||
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
|
||||
>
|
||||
Включить уведомления
|
||||
</button>
|
||||
<button
|
||||
v-if="isSupported && permissionGranted && !subscription"
|
||||
@click="subscribe"
|
||||
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg font-medium transition"
|
||||
>
|
||||
Подписаться на пуши
|
||||
</button>
|
||||
<div v-if="subscription" class="text-sm text-emerald-400">
|
||||
✅ Подписка активна
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>🏆</span> Ачивки
|
||||
</h2>
|
||||
<div v-if="achievements.length === 0" class="text-slate-400 text-sm">
|
||||
Пока нет разблокированных ачивок. Завершите задачи в Canvas!
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="ach in achievements"
|
||||
:key="ach.id"
|
||||
class="flex items-center gap-3 p-3 bg-slate-700 rounded-lg"
|
||||
>
|
||||
<span class="text-xl">{{ ach.icon || '🏆' }}</span>
|
||||
<div>
|
||||
<div class="font-medium">{{ ach.title }}</div>
|
||||
<div class="text-xs text-slate-400">{{ ach.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const { user } = useAuth()
|
||||
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
|
||||
const { achievements, loadAchievements } = useAchievements()
|
||||
|
||||
const permissionGranted = ref(false)
|
||||
const saving = ref(false)
|
||||
const saveMessage = ref(null)
|
||||
const userGroups = ref([])
|
||||
|
||||
const profileForm = reactive({
|
||||
displayName: '',
|
||||
username: '',
|
||||
bio: '',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/users/${user.value.id}/profile`, {
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
const groupsData = await $fetch('/api/user/groups', {
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
userGroups.value = groupsData.groups || []
|
||||
|
||||
profileForm.displayName = data.profile.displayName || ''
|
||||
profileForm.username = data.profile.user.username || ''
|
||||
profileForm.bio = data.profile.bio || ''
|
||||
profileForm.avatar = data.profile.avatar || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to load profile', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
saving.value = true
|
||||
saveMessage.value = null
|
||||
try {
|
||||
await $fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
body: profileForm,
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
saveMessage.value = { type: 'success', text: 'Профиль обновлен успешно!' }
|
||||
// Update local user data if needed
|
||||
user.value.username = profileForm.username
|
||||
} catch (e) {
|
||||
saveMessage.value = { type: 'error', text: e.data?.message || 'Ошибка при сохранении' }
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const enablePush = async () => {
|
||||
const granted = await requestPermission()
|
||||
permissionGranted.value = granted
|
||||
if (granted) {
|
||||
await pushSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = async () => {
|
||||
await pushSubscribe()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
permissionGranted.value = Notification.permission === 'granted'
|
||||
await Promise.all([
|
||||
loadAchievements(),
|
||||
loadProfile()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user