feat: prisma schema, groups API, deploy workflow
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
export interface Achievement {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
unlockedAt?: string
|
||||
}
|
||||
|
||||
export const useAchievements = () => {
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const stats = ref({ total: 0, completedTasks: 0 })
|
||||
const pending = ref(false)
|
||||
|
||||
const loadAchievements = async () => {
|
||||
pending.value = true
|
||||
try {
|
||||
const data = await $fetch<{ achievements: Achievement[], stats: { total: number, completedTasks: number } }>('/api/achievements')
|
||||
achievements.value = data.achievements || []
|
||||
stats.value = data.stats || { total: 0, completedTasks: 0 }
|
||||
} catch (e) {
|
||||
console.error('Failed to load achievements:', e)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
achievements,
|
||||
stats,
|
||||
pending,
|
||||
loadAchievements
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const token = useCookie<string | null>('xflow_token', {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
sameSite: 'strict'
|
||||
})
|
||||
|
||||
const user = computed<User | null>(() => {
|
||||
if (!token.value) return null
|
||||
try {
|
||||
return jwtDecode<User>(token.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
const googleSignIn = async () => {
|
||||
const auth = getAuth()
|
||||
const provider = new GoogleAuthProvider()
|
||||
const result = await signInWithPopup(auth, provider)
|
||||
const idToken = await result.user.getIdToken()
|
||||
|
||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: { idToken }
|
||||
})
|
||||
|
||||
token.value = res.token
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
const auth = getAuth()
|
||||
await signOut(auth)
|
||||
token.value = null
|
||||
navigateTo('/login')
|
||||
}
|
||||
|
||||
const initAuth = () => {
|
||||
const auth = getAuth()
|
||||
onAuthStateChanged(auth, async (firebaseUser) => {
|
||||
if (firebaseUser) {
|
||||
const idToken = await firebaseUser.getIdToken()
|
||||
if (idToken !== token.value) {
|
||||
token.value = idToken
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
googleSignIn,
|
||||
logout,
|
||||
initAuth
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
text?: string
|
||||
file?: string
|
||||
frontmatter?: Record<string, any>
|
||||
state?: 'pending' | 'in_progress' | 'completed'
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string
|
||||
fromNode: string
|
||||
fromSide: string
|
||||
toNode: string
|
||||
toSide: string
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
nodes: CanvasNode[]
|
||||
edges: CanvasEdge[]
|
||||
}
|
||||
|
||||
export const useCanvas = () => {
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
})
|
||||
|
||||
const renderMarkdown = (text: string): string => {
|
||||
return md.render(text || '')
|
||||
}
|
||||
|
||||
const parseCanvasJson = (json: any): CanvasData => {
|
||||
const nodes = (json.nodes || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
width: n.width || 300,
|
||||
height: n.height || 200,
|
||||
text: n.text,
|
||||
file: n.file,
|
||||
frontmatter: n.frontmatter,
|
||||
state: n.state || 'pending',
|
||||
style: n.style || {}
|
||||
}))
|
||||
|
||||
const edges = (json.edges || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
fromNode: e.fromNode || e.source,
|
||||
fromSide: e.fromSide || 'right',
|
||||
toNode: e.toNode || e.target,
|
||||
toSide: e.toSide || 'left'
|
||||
}))
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const loadCanvas = async (id: string): Promise<CanvasData> => {
|
||||
const data = await $fetch(`/api/canvas/${id}`)
|
||||
return parseCanvasJson(data)
|
||||
}
|
||||
|
||||
const updateNodeState = async (nodeId: string, state: string) => {
|
||||
return await $fetch(`/api/nodes/${nodeId}`, {
|
||||
method: 'PUT',
|
||||
body: { state }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
renderMarkdown,
|
||||
parseCanvasJson,
|
||||
loadCanvas,
|
||||
updateNodeState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Composable for Git synchronization functionality
|
||||
interface GitRepository {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
url: string
|
||||
cloneUrl: string
|
||||
isPrivate: boolean
|
||||
type: 'personal' | 'group'
|
||||
ownerId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const useGitSync = () => {
|
||||
const auth = useAuth()
|
||||
const user = computed(() => auth.user)
|
||||
|
||||
const apiFetch = (url: string, opts: any = {}) => $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
|
||||
const isSyncing = ref(false)
|
||||
const lastSyncTime = ref<string | null>(null)
|
||||
const syncStatus = ref<'idle' | 'syncing' | 'success' | 'error'>('idle')
|
||||
const repository = ref<GitRepository | null>(null)
|
||||
|
||||
// Auto-sync canvas changes
|
||||
const autoSyncCanvas = async (canvasId: string, canvasData: any) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: `Auto-sync: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
|
||||
// Show success notification
|
||||
console.log('Canvas synchronized with Git:', response.message)
|
||||
} else {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Git sync failed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Auto-sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
// Reset status after delay
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create Git repository for canvas
|
||||
const createRepository = async (canvasId: string, type: 'personal' | 'group' = 'personal') => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'init'
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
repository.value = response.repository
|
||||
console.log('Git repository created:', response.message)
|
||||
return response.repository
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create repository:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Manual sync
|
||||
const manualSync = async (canvasId: string, commitMessage?: string) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: commitMessage || `Ручная синхронизация: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
console.log('Manual sync completed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Manual sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if canvas has repository
|
||||
const checkRepository = async (canvasId: string) => {
|
||||
if (!canvasId) return null
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/git/repository/${canvasId}`) as any
|
||||
repository.value = response.repository
|
||||
return response.repository
|
||||
} catch (error) {
|
||||
repository.value = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status color
|
||||
const syncStatusColor = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return 'text-yellow-400'
|
||||
case 'success': return 'text-green-400'
|
||||
case 'error': return 'text-red-400'
|
||||
default: return 'text-slate-400'
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync status icon
|
||||
const syncStatusIcon = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return '⟳'
|
||||
case 'success': return '✓'
|
||||
case 'error': return '✗'
|
||||
default: return '⚡'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isSyncing: readonly(isSyncing),
|
||||
lastSyncTime: readonly(lastSyncTime),
|
||||
syncStatus: readonly(syncStatus),
|
||||
repository: readonly(repository),
|
||||
syncStatusColor,
|
||||
syncStatusIcon,
|
||||
autoSyncCanvas,
|
||||
createRepository,
|
||||
manualSync,
|
||||
checkRepository
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export const usePush = () => {
|
||||
const isSupported = ref(false)
|
||||
const subscription = ref<PushSubscription | null>(null)
|
||||
|
||||
const checkSupport = () => {
|
||||
isSupported.value = 'serviceWorker' in navigator && 'PushManager' in window
|
||||
}
|
||||
|
||||
const getRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||
if (!('serviceWorker' in navigator)) return null
|
||||
return navigator.serviceWorker.ready
|
||||
}
|
||||
|
||||
const subscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
try {
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
subscription.value = existing
|
||||
await sendSubscriptionToServer(existing)
|
||||
return true
|
||||
}
|
||||
|
||||
// In production, get VAPID public key from server
|
||||
const vapidKey = await $fetch<string>('/api/push/vapid-public-key').catch(() => null)
|
||||
const applicationServerKey = vapidKey
|
||||
? (urlBase64ToUint8Array(vapidKey) as unknown as ArrayBuffer)
|
||||
: undefined
|
||||
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
|
||||
subscription.value = sub
|
||||
await sendSubscriptionToServer(sub)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Push subscription failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendSubscriptionToServer = async (sub: PushSubscription) => {
|
||||
await $fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: { subscription: sub.toJSON() }
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
await existing.unsubscribe()
|
||||
subscription.value = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
const showLocalNotification = (title: string, options?: NotificationOptions) => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
reg.showNotification(title, {
|
||||
icon: '/pwa-192x192.svg',
|
||||
badge: '/favicon.svg',
|
||||
...options as any
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
if (!('Notification' in window)) return false
|
||||
const permission = await Notification.requestPermission()
|
||||
return permission === 'granted'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkSupport()
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
subscription,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestPermission,
|
||||
showLocalNotification
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Composable for tracking unsaved changes and preventing accidental data loss
|
||||
export const useUnsavedChanges = () => {
|
||||
const isDirty = ref(false)
|
||||
const lastSavedData = ref<string | null>(null)
|
||||
const currentData = ref<string | null>(null)
|
||||
|
||||
// Track changes by comparing current data with last saved data
|
||||
const trackChanges = (newData: any) => {
|
||||
currentData.value = JSON.stringify(newData)
|
||||
|
||||
if (lastSavedData.value && currentData.value !== lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
} else if (!lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Mark data as saved
|
||||
const markAsSaved = (data: any) => {
|
||||
lastSavedData.value = JSON.stringify(data)
|
||||
currentData.value = lastSavedData.value
|
||||
isDirty.value = false
|
||||
}
|
||||
|
||||
// Reset dirty state
|
||||
const resetDirty = () => {
|
||||
isDirty.value = false
|
||||
lastSavedData.value = currentData.value
|
||||
}
|
||||
|
||||
// Warning before leaving page
|
||||
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
|
||||
if (isDirty.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?'
|
||||
return event.returnValue
|
||||
}
|
||||
}
|
||||
|
||||
// Setup warning on mount
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
return {
|
||||
isDirty: readonly(isDirty),
|
||||
trackChanges,
|
||||
markAsSaved,
|
||||
resetDirty
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user