feat: prisma schema, groups API, deploy workflow

This commit is contained in:
2026-04-23 21:41:11 +05:00
parent 4c50036172
commit 65cf8ca01f
285 changed files with 24602 additions and 25890 deletions
+33
View File
@@ -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
}
}
+72
View File
@@ -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
}
}
+85
View File
@@ -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
}
}
+175
View File
@@ -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
}
}
+102
View File
@@ -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
}
}
+56
View File
@@ -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
}
}