Files
Avtex/apps/web/app/pages/canvases.vue
T

441 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Debug Info for Prod -->
<div v-if="auth.user.value" class="mb-4 p-2 bg-slate-800 border border-slate-700 rounded text-[10px] font-mono text-slate-500 flex justify-between items-center">
<span>Logged in as: <b class="text-blue-400">{{ auth.user.value.email }}</b></span>
<span>Internal ID: <b class="text-emerald-400">{{ auth.user.value.id }}</b></span>
</div>
<div class="flex items-center justify-between mb-8">
<h1 class="text-2xl font-bold text-slate-100">Мои Карты</h1>
<div class="flex items-center gap-3">
<button @click="triggerImport" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition">
Импорт .canvas
</button>
<input ref="importInput" type="file" accept=".canvas,.json" class="hidden" @change="handleImport" />
<button @click="openCreateModal" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-bold shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
<span>➕</span> Создать
</button>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
<button @click="activeTab = 'personal'" :class="tabClass('personal')" class="px-4 py-2 rounded text-sm font-medium transition">
Личные
</button>
<button @click="activeTab = 'team'" :class="tabClass('team')" class="px-4 py-2 rounded text-sm font-medium transition">
Командные
</button>
<button @click="activeTab = 'public'" :class="tabClass('public')" class="px-4 py-2 rounded text-sm font-medium transition">
Публичные
</button>
</div>
<!-- Canvas Grid -->
<div v-if="filteredCanvases.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="canvas in filteredCanvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer group"
@click="openCanvas(canvas.id)">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-slate-200 group-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">Публичная</span>
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Общая</span>
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Приватная</span>
</div>
</div>
<div class="text-xs text-slate-400">
<span v-if="canvas.ownerId === currentUserId">Ваша карта</span>
<span v-else>Поделился {{ getOwnerName(canvas.ownerId) }}</span>
<span v-if="canvas.groupId" class="ml-2">• {{ getGroupName(canvas.groupId) }}</span>
</div>
<div class="mt-3 pt-3 border-t border-slate-800 flex justify-between items-center">
<div class="flex gap-3">
<button @click.stop="duplicateCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-blue-400 transition flex items-center gap-1">
<span>📑</span> Дублировать
</button>
<button @click.stop="exportCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-emerald-400 transition flex items-center gap-1">
<span>💾</span> Экспорт
</button>
</div>
<div class="flex items-center gap-1">
<button v-if="canDeleteCanvas(canvas)" @click.stop="openSettings(canvas)"
class="p-1.5 text-slate-500 hover:text-blue-400 hover:bg-blue-400/10 rounded transition-colors"
title="Настройки доступа">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button v-if="canDeleteCanvas(canvas)" @click.stop="deleteCanvas(canvas.id)"
class="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded transition-colors"
title="Удалить карту">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<div class="text-slate-500 mb-4">Карты не найдены</div>
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
Создать первую карту
</button>
</div>
</div>
<!-- Create Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 class="text-xl font-bold text-slate-100 mb-6 flex items-center gap-2">
<span>✨</span> Новая Карта
</h2>
<form @submit.prevent="createCanvas" class="space-y-6">
<div>
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Название Карты</label>
<input v-model="newCanvas.name" required class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition" placeholder="Моя Awesome Карта" />
</div>
<div>
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Видимость</label>
<select v-model="newCanvas.visibility" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
<option value="private">Приватная (только вы)</option>
<option value="public">Публичная (любой может просматривать)</option>
</select>
</div>
<div v-if="userGroups.length > 0">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Поделиться с Группой (опционально)</label>
<select v-model="newCanvas.groupId" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
<option value="">Без группы</option>
<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/20">
Создать
</button>
<button type="button" @click="showCreateModal = false" class="flex-1 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
Отмена
</button>
</div>
</form>
</div>
</div>
<!-- Settings Modal -->
<div v-if="showSettingsModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="closeSettings">
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 class="text-xl font-bold text-slate-100 mb-6 flex items-center gap-2">
<span>⚙️</span> Настройки доступа
</h2>
<form @submit.prevent="saveSettings" class="space-y-6">
<div>
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Название Карты</label>
<input
v-model="editCanvas.name"
required
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition"
/>
</div>
<div>
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Уровень доступа</label>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
@click="editCanvas.isPublic = false"
:class="!editCanvas.isPublic ? 'bg-blue-600 border-blue-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-400'"
class="px-4 py-2 border rounded-xl text-sm font-medium transition"
>
Приватная
</button>
<button
type="button"
@click="editCanvas.isPublic = true"
:class="editCanvas.isPublic ? 'bg-emerald-600 border-emerald-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-400'"
class="px-4 py-2 border rounded-xl text-sm font-medium transition"
>
Публичная
</button>
</div>
<p class="text-[10px] text-slate-500 mt-2">
{{ editCanvas.isPublic ? 'Любой пользователь сможет просматривать эту карту.' : 'Карта доступна только вам и членам выбранной группы.' }}
</p>
</div>
<div v-if="userGroups.length > 0">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Принадлежность Группе</label>
<select v-model="editCanvas.groupId" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
<option :value="null">Без группы (Личная)</option>
<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/20" :disabled="saving">
{{ saving ? 'Сохранение...' : 'Сохранить' }}
</button>
<button type="button" @click="closeSettings" class="flex-1 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
Отмена
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const user = computed(() => auth.user.value)
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 canvases = ref([])
const userGroups = ref([])
const showCreateModal = ref(false)
const importInput = ref(null)
const activeTab = ref('personal')
const newCanvas = reactive({
name: '',
visibility: 'private',
groupId: ''
})
const openCreateModal = () => {
newCanvas.name = ''
newCanvas.visibility = 'private'
newCanvas.groupId = ''
showSettingsModal.value = false
showCreateModal.value = true
}
const showSettingsModal = ref(false)
const saving = ref(false)
const editCanvas = reactive({
id: '',
name: '',
isPublic: false,
groupId: null
})
const openSettings = (canvas) => {
editCanvas.id = canvas.id
editCanvas.name = canvas.name
editCanvas.isPublic = canvas.isPublic
editCanvas.groupId = canvas.groupId
showSettingsModal.value = true
}
const closeSettings = () => {
showSettingsModal.value = false
}
const saveSettings = async () => {
saving.value = true
try {
await authFetch(`/api/canvases/${editCanvas.id}`, {
method: 'PATCH',
body: {
name: editCanvas.name,
isPublic: editCanvas.isPublic,
groupId: editCanvas.groupId
}
})
await loadData()
closeSettings()
} catch (error) {
alert('Ошибка при сохранении: ' + (error.data?.message || error.message))
} finally {
saving.value = false
}
}
const currentUserId = computed(() => user.value?.id || '')
// Load data
const loadData = async () => {
try {
const [canvasesRes, groupsRes] = await Promise.all([
authFetch('/api/canvas'),
authFetch('/api/user/groups')
])
canvases.value = canvasesRes.canvases || []
userGroups.value = groupsRes.groups || []
} catch (error) {
console.error('Failed to load data:', error)
}
}
// Filter canvases based on active tab
const filteredCanvases = computed(() => {
const userId = currentUserId.value
if (!userId) return []
switch (activeTab.value) {
case 'personal':
return canvases.value.filter(c => c.ownerId === userId && (!c.groupId || c.groupId === ''))
case 'team':
return canvases.value.filter(c => c.groupId && c.groupId !== '')
case 'public':
return canvases.value.filter(c => c.isPublic)
default:
return canvases.value
}
})
// Tab styling
const tabClass = (tab) => {
return activeTab.value === tab
? 'bg-slate-800 text-slate-200'
: 'text-slate-400 hover:text-slate-300'
}
// Canvas actions
const openCanvas = (id) => {
navigateTo(`/canvas/${id}`)
}
const createCanvas = async () => {
try {
const payload = {
name: newCanvas.name,
isPublic: newCanvas.visibility === 'public',
groupId: newCanvas.groupId || undefined
}
const result = await authFetch('/api/canvas', { method: 'POST', body: payload })
showCreateModal.value = false
newCanvas.name = ''
newCanvas.visibility = 'private'
newCanvas.groupId = ''
await loadData()
navigateTo(`/canvas/${result.canvas.id}`)
} catch (error) {
console.error('Failed to create canvas:', error)
}
}
const duplicateCanvas = async (id) => {
try {
const result = await authFetch(`/api/canvas/${id}/duplicate`, { method: 'POST' })
await loadData()
} catch (error) {
console.error('Failed to duplicate canvas:', error)
}
}
const triggerImport = () => {
importInput.value?.click()
}
const handleImport = async (event) => {
const file = event.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
const name = file.name.replace(/\.canvas$|\.json$/, '')
const payload = {
name,
nodes: data.nodes || [],
edges: data.edges || [],
isPublic: false
}
const result = await authFetch('/api/canvas', { method: 'POST', body: payload })
await loadData()
navigateTo(`/canvas/${result.canvas.id}`)
} catch (error) {
console.error('Failed to import canvas:', error)
alert('Ошибка импорта: проверьте формат файла')
} finally {
event.target.value = ''
}
}
const exportCanvas = async (id) => {
try {
const canvas = await authFetch(`/api/canvas/${id}`)
const blob = new Blob([JSON.stringify(canvas, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${canvas.name || 'canvas'}.canvas`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to export canvas:', error)
}
}
const canDeleteCanvas = (canvas) => {
return canvas.ownerId === currentUserId.value
}
const deleteCanvas = async (id) => {
if (!confirm('Вы уверены, что хотите удалить эту карту?')) return
try {
await authFetch(`/api/canvas/${id}`, { method: 'DELETE' })
await loadData()
} catch (error) {
console.error('Failed to delete canvas:', error)
}
}
// Helper functions
const formatDate = (date) => {
return new Date(date).toLocaleDateString('ru-RU', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getOwnerName = (ownerId) => {
return ownerId === currentUserId.value ? 'вы' : `Пользователь ${ownerId}`
}
const getGroupName = (groupId) => {
const group = userGroups.value.find(g => g.id === groupId)
return group?.name || 'Unknown Group'
}
onMounted(() => {
if (auth.isAuthenticated.value) {
loadData()
}
})
watch(() => auth.isAuthenticated.value, (isAuth) => {
if (isAuth) loadData()
})
</script>