stuff
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions

This commit is contained in:
Dmitry 2025-12-14 22:21:36 +03:00
parent 50e7e75a56
commit 889784e11f
13 changed files with 2149 additions and 104 deletions

View file

@ -0,0 +1,356 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { cardsApi } from '@/api/cards'
import type { GameCardDto } from '@/types/models'
import type { AxiosError } from 'axios'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Label } from './ui/label'
import { Textarea } from './ui/textarea'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
import { Badge } from './ui/badge'
import { packsApi } from '@/api/packs'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
import { ChevronLeft, ChevronRight, Save, X } from 'lucide-react'
interface UploadedImage {
id: string
file: File
preview: string
base64: string
}
interface CardData {
imageId: string
packId: string
original: string
translation: string
mnemo: string
transcription: string
transcriptionMnemo: string
back: string
imageBack?: string
isSaved: boolean
cardId?: number
}
interface BulkCardEditorProps {
images: UploadedImage[]
onComplete: () => void
onCancel: () => void
}
export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorProps) {
const queryClient = useQueryClient()
const [currentIndex, setCurrentIndex] = useState(0)
const [cardsData, setCardsData] = useState<Map<string, CardData>>(new Map())
const [packsData, setPacksData] = useState<{ id: string; title: string }[]>([])
// Load packs
useEffect(() => {
packsApi
.getPacks({ page: 1, limit: 1000, search: '' })
.then((response) => {
setPacksData(response.items.map((pack) => ({ id: pack.id, title: pack.title })))
})
.catch(() => {
toast.error('Failed to load packs')
})
}, [])
// Initialize cards data
useEffect(() => {
const initialData = new Map<string, CardData>()
images.forEach((image) => {
initialData.set(image.id, {
imageId: image.id,
packId: '',
original: '',
translation: '',
mnemo: '',
transcription: '',
transcriptionMnemo: '',
back: '',
imageBack: undefined,
isSaved: false,
})
})
setCardsData(initialData)
}, [images])
const currentImage = images[currentIndex]
const currentCard = cardsData.get(currentImage?.id || '')
const updateMutation = useMutation({
mutationFn: (card: GameCardDto) => cardsApi.upsertCard(card),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['cards'] })
const imageId = currentImage.id
setCardsData((prev) => {
const newData = new Map(prev)
const cardData = newData.get(imageId)
if (cardData && response.card) {
newData.set(imageId, {
...cardData,
isSaved: true,
cardId: response.card.id,
})
}
return newData
})
toast.success('Card saved successfully')
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(axiosError.response?.data?.message || 'Failed to save card')
},
})
const handleFieldChange = (field: keyof CardData, value: string) => {
if (!currentCard) return
setCardsData((prev) => {
const newData = new Map(prev)
const cardData = newData.get(currentImage.id)
if (cardData) {
newData.set(currentImage.id, {
...cardData,
[field]: value,
})
}
return newData
})
}
const handleSave = () => {
if (!currentCard) return
if (!currentCard.original.trim() || !currentCard.translation.trim() || !currentCard.mnemo.trim()) {
toast.error('Original, translation and mnemo are required')
return
}
const image = images.find((img) => img.id === currentCard.imageId)
if (!image) return
const cardData: GameCardDto = {
id: currentCard.cardId || -1,
packId: currentCard.packId || undefined,
original: currentCard.original.trim(),
translation: currentCard.translation.trim(),
mnemo: currentCard.mnemo.trim(),
transcription: currentCard.transcription.trim() || undefined,
transcriptionMnemo: currentCard.transcriptionMnemo.trim() || undefined,
back: currentCard.back.trim() || undefined,
image: image.base64,
imageBack: currentCard.imageBack || undefined,
}
updateMutation.mutate(cardData)
}
const handleNext = () => {
if (currentIndex < images.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
const handlePrevious = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const savedCount = Array.from(cardsData.values()).filter((card) => card.isSaved).length
const totalCount = images.length
if (!currentImage || !currentCard) {
return <div>Loading...</div>
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Fill Card Details</h2>
<p className="text-sm text-muted-foreground">
Card {currentIndex + 1} of {totalCount} {savedCount} saved
</p>
</div>
<div className="flex items-center space-x-2">
<Badge variant={currentCard.isSaved ? 'default' : 'secondary'}>
{currentCard.isSaved ? 'Saved' : 'Not Saved'}
</Badge>
<Button variant="outline" onClick={onCancel}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Image Preview */}
<Card>
<CardHeader>
<CardTitle>Image Preview</CardTitle>
<CardDescription>{currentImage.file.name}</CardDescription>
</CardHeader>
<CardContent>
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
<img
src={currentImage.preview}
alt={currentImage.file.name}
className="w-full h-full object-contain"
/>
</div>
</CardContent>
</Card>
{/* Form */}
<Card>
<CardHeader>
<CardTitle>Card Information</CardTitle>
<CardDescription>Fill in the details for this card</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="packId">Pack ID (optional)</Label>
<Select
value={currentCard.packId}
onValueChange={(value) => handleFieldChange('packId', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a pack (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{packsData.map((pack) => (
<SelectItem key={pack.id} value={pack.id}>
{pack.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="original">Original *</Label>
<Input
id="original"
value={currentCard.original}
onChange={(e) => handleFieldChange('original', e.target.value)}
placeholder="e.g. cerdo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="translation">Translation *</Label>
<Input
id="translation"
value={currentCard.translation}
onChange={(e) => handleFieldChange('translation', e.target.value)}
placeholder="e.g. pig"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="mnemo">Mnemo *</Label>
<Input
id="mnemo"
value={currentCard.mnemo}
onChange={(e) => handleFieldChange('mnemo', e.target.value)}
placeholder="e.g. [pig] with heart"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="transcription">Transcription</Label>
<Input
id="transcription"
value={currentCard.transcription}
onChange={(e) => handleFieldChange('transcription', e.target.value)}
placeholder="e.g. sɛrdo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="transcriptionMnemo">Transcription Mnemo</Label>
<Input
id="transcriptionMnemo"
value={currentCard.transcriptionMnemo}
onChange={(e) => handleFieldChange('transcriptionMnemo', e.target.value)}
placeholder="e.g. pig with heart"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="back">Back Side</Label>
<Textarea
id="back"
value={currentCard.back}
onChange={(e) => handleFieldChange('back', e.target.value)}
placeholder="Additional information on the back of the card"
rows={3}
/>
</div>
<div className="flex items-center space-x-2 pt-4">
<Button
variant="outline"
onClick={handlePrevious}
disabled={currentIndex === 0}
>
<ChevronLeft className="h-4 w-4 mr-2" />
Previous
</Button>
<Button
onClick={handleSave}
disabled={updateMutation.isPending}
className="flex-1"
>
<Save className="h-4 w-4 mr-2" />
{updateMutation.isPending ? 'Saving...' : currentCard.isSaved ? 'Update' : 'Save'}
</Button>
<Button
variant="outline"
onClick={handleNext}
disabled={currentIndex === images.length - 1}
>
Next
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Progress indicator */}
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Progress</span>
<span>{savedCount} / {totalCount} saved</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${(savedCount / totalCount) * 100}%` }}
/>
</div>
</div>
{savedCount === totalCount && (
<div className="mt-4 flex justify-end">
<Button onClick={onComplete}>
Complete
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,248 @@
import { useState, useRef } from 'react'
import { Button } from './ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
import { Label } from './ui/label'
import { Upload, X, Image as ImageIcon, Check } from 'lucide-react'
import { Badge } from './ui/badge'
interface UploadedImage {
id: string
file: File
preview: string
base64: string
}
interface BulkCardUploadProps {
onImagesUploaded: (images: UploadedImage[]) => void
onClose: () => void
}
export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([])
const [isDragging, setIsDragging] = useState(false)
const handleFileSelect = async (files: FileList) => {
const newImages: UploadedImage[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
// Validate file type
if (!file.type.startsWith('image/')) {
continue
}
// Validate file size (max 5MB)
const fileSizeMB = file.size / (1024 * 1024)
if (fileSizeMB > 5) {
continue
}
try {
const base64 = await fileToBase64(file)
const preview = URL.createObjectURL(file)
newImages.push({
id: `${Date.now()}-${i}`,
file,
preview,
base64,
})
} catch (error) {
console.error('Error processing file:', error)
}
}
setUploadedImages((prev) => [...prev, ...newImages])
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
handleFileSelect(files)
}
// Reset input value to allow selecting the same files again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer.files
if (files && files.length > 0) {
handleFileSelect(files)
}
}
const handleRemove = (id: string) => {
setUploadedImages((prev) => {
const image = prev.find((img) => img.id === id)
if (image) {
URL.revokeObjectURL(image.preview)
}
return prev.filter((img) => img.id !== id)
})
}
const handleClearAll = () => {
uploadedImages.forEach((img) => URL.revokeObjectURL(img.preview))
setUploadedImages([])
}
const handleContinue = () => {
if (uploadedImages.length > 0) {
onImagesUploaded(uploadedImages)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Bulk Card Upload</h2>
<p className="text-sm text-muted-foreground">
Upload multiple images, then fill in card details one by one
</p>
</div>
<Button variant="outline" onClick={onClose}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Upload Images</CardTitle>
<CardDescription>
Select multiple image files or drag and drop them here
</CardDescription>
</CardHeader>
<CardContent>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleInputChange}
className="hidden"
/>
<div className="flex flex-col items-center justify-center space-y-4">
<Upload className="h-12 w-12 text-muted-foreground" />
<div className="text-lg">
<span className="text-primary font-medium">Click to upload</span> or drag and drop
</div>
<p className="text-sm text-muted-foreground">
PNG, JPG, GIF up to 5MB each. Multiple files supported.
</p>
</div>
</div>
</CardContent>
</Card>
{uploadedImages.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Uploaded Images ({uploadedImages.length})</CardTitle>
<CardDescription>
Review uploaded images and continue to fill in card details
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={handleClearAll}>
<X className="h-4 w-4 mr-2" />
Clear All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 max-h-[500px] overflow-y-auto">
{uploadedImages.map((image) => (
<div key={image.id} className="relative group">
<div className="relative aspect-square border rounded-lg overflow-hidden bg-muted">
<img
src={image.preview}
alt={image.file.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
<Button
variant="destructive"
size="sm"
className="opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
handleRemove(image.id)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1 truncate" title={image.file.name}>
{image.file.name}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{uploadedImages.length > 0 && (
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleContinue}>
<Check className="h-4 w-4 mr-2" />
Continue to Fill Details ({uploadedImages.length} cards)
</Button>
</div>
)}
</div>
)
}
// Helper function to convert file to base64
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Remove data:image/...;base64, prefix
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}

View file

@ -37,7 +37,11 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { ImageUpload } from '@/components/ui/image-upload'
import { CardVoicesManager } from '@/components/CardVoicesManager'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
import { BulkCardUpload } from '@/components/BulkCardUpload'
import { BulkCardEditor } from '@/components/BulkCardEditor'
import { packsApi } from '@/api/packs'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight, Upload } from 'lucide-react'
export default function CardsPage() {
const queryClient = useQueryClient()
@ -47,9 +51,18 @@ export default function CardsPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [cardToDelete, setCardToDelete] = useState<GameCardDto | null>(null)
const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false)
const [isBulkEditorOpen, setIsBulkEditorOpen] = useState(false)
const [uploadedImages, setUploadedImages] = useState<Array<{
id: string
file: File
preview: string
base64: string
}>>([])
// Form state
const [formData, setFormData] = useState({
packId: '',
original: '',
translation: '',
mnemo: '',
@ -109,9 +122,16 @@ export default function CardsPage() {
},
})
// Fetch packs for packId selection
const { data: packsData } = useQuery({
queryKey: ['packs', 1, 1000, ''],
queryFn: () => packsApi.getPacks({ page: 1, limit: 1000, search: '' }),
})
const openCreateDialog = () => {
setSelectedCard(null)
setFormData({
packId: '',
original: '',
translation: '',
mnemo: '',
@ -127,6 +147,7 @@ export default function CardsPage() {
const openEditDialog = (card: GameCardDto) => {
setSelectedCard(card)
setFormData({
packId: (card as any).packId || '',
original: card.original || '',
translation: card.translation || '',
mnemo: card.mnemo || '',
@ -154,6 +175,7 @@ export default function CardsPage() {
const cardData: GameCardDto = {
id: selectedCard?.id || -1,
packId: formData.packId.trim() || undefined,
original: formData.original.trim(),
translation: formData.translation.trim(),
mnemo: formData.mnemo.trim(),
@ -212,11 +234,17 @@ export default function CardsPage() {
View, create, edit and delete game cards
</p>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" onClick={() => setIsBulkUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" />
Bulk Upload
</Button>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Card
</Button>
</div>
</div>
{/* Search */}
<Card>
@ -335,6 +363,26 @@ export default function CardsPage() {
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="packId">Pack ID (optional)</Label>
<Select
value={formData.packId}
onValueChange={(value) => setFormData(prev => ({ ...prev, packId: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a pack (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{packsData?.items.map((pack) => (
<SelectItem key={pack.id} value={pack.id}>
{pack.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="original">Original *</Label>
@ -462,6 +510,46 @@ export default function CardsPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bulk Upload Dialog */}
{isBulkUploadOpen && !isBulkEditorOpen && (
<Dialog open={isBulkUploadOpen} onOpenChange={setIsBulkUploadOpen}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<BulkCardUpload
onImagesUploaded={(images) => {
setUploadedImages(images)
setIsBulkUploadOpen(false)
setIsBulkEditorOpen(true)
}}
onClose={() => {
setIsBulkUploadOpen(false)
setUploadedImages([])
}}
/>
</DialogContent>
</Dialog>
)}
{/* Bulk Editor Dialog */}
{isBulkEditorOpen && (
<Dialog open={isBulkEditorOpen} onOpenChange={setIsBulkEditorOpen}>
<DialogContent className="sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
<BulkCardEditor
images={uploadedImages}
onComplete={() => {
setIsBulkEditorOpen(false)
setUploadedImages([])
queryClient.invalidateQueries({ queryKey: ['cards'] })
toast.success('All cards saved successfully!')
}}
onCancel={() => {
setIsBulkEditorOpen(false)
setUploadedImages([])
}}
/>
</DialogContent>
</Dialog>
)}
</div>
)
}

View file

@ -62,6 +62,8 @@ export default function PacksPage() {
appStoreId: '',
price: '',
order: 0,
version: '',
size: 0,
cover: undefined as string | undefined,
})
@ -132,6 +134,8 @@ export default function PacksPage() {
appStoreId: '',
price: '',
order: 0,
version: '',
size: 0,
cover: undefined,
})
setCurrentCardIds([])
@ -155,6 +159,8 @@ export default function PacksPage() {
appStoreId: fullPack.appStoreId || '',
price: fullPack.price || '',
order: fullPack.order || 0,
version: fullPack.version || '',
size: fullPack.size || 0,
cover: fullPack.cover,
})
// Initialize current card IDs from addCardIds (which contains all cards in pack)
@ -201,6 +207,8 @@ export default function PacksPage() {
appStoreId: formData.appStoreId.trim() || undefined,
price: formData.price.trim() || undefined,
order: formData.order,
version: formData.version.trim() || undefined,
size: formData.size > 0 ? formData.size : undefined,
cover: formData.cover || undefined,
addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
@ -501,6 +509,7 @@ export default function PacksPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="price">Price</Label>
<Input
@ -510,6 +519,30 @@ export default function PacksPage() {
placeholder="Free, $1.99, etc."
/>
</div>
<div className="space-y-2">
<Label htmlFor="version">Version</Label>
<Input
id="version"
value={formData.version}
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
placeholder="1.0.0"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="size">Size</Label>
<Input
id="size"
type="number"
value={formData.size}
onChange={(e) => setFormData(prev => ({ ...prev, size: parseInt(e.target.value) || 0 }))}
placeholder="0"
/>
<p className="text-xs text-muted-foreground">
Number of cards in the pack (auto-calculated if not set)
</p>
</div>
<div className="space-y-2">
<ImageUpload

View file

@ -36,6 +36,7 @@ import {
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { packsApi } from '@/api/packs'
import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
export default function UsersPage() {
@ -54,6 +55,9 @@ export default function UsersPage() {
name: '',
email: '',
admin: false,
subscription: false,
packs: [] as string[],
subscriptionFeatures: [] as string[],
})
const limit = 20
@ -64,6 +68,12 @@ export default function UsersPage() {
queryFn: () => usersApi.getUsers({ page, limit }),
})
// Fetch packs for selection
const { data: packsData } = useQuery({
queryKey: ['packs', 1, 1000, ''],
queryFn: () => packsApi.getPacks({ page: 1, limit: 1000, search: '', showDisabled: true }),
})
// Mutations
const updateMutation = useMutation({
mutationFn: (user: UserDto) => usersApi.upsertUser(user),
@ -98,6 +108,9 @@ export default function UsersPage() {
name: user.name || '',
email: user.email || '',
admin: user.admin,
subscription: user.subscription || false,
packs: [...user.packs],
subscriptionFeatures: [...user.subscriptionFeatures],
})
setIsEditDialogOpen(true)
}
@ -150,9 +163,10 @@ export default function UsersPage() {
name: formData.name.trim() || undefined,
email: formData.email.trim() || undefined,
admin: formData.admin,
packs: selectedUser.packs,
subscription: formData.subscription,
packs: formData.packs,
purchases: selectedUser.purchases,
subscriptionFeatures: selectedUser.subscriptionFeatures,
subscriptionFeatures: formData.subscriptionFeatures,
}
updateMutation.mutate(userData)
@ -351,6 +365,7 @@ export default function UsersPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="admin"
@ -359,12 +374,84 @@ export default function UsersPage() {
/>
<Label htmlFor="admin">Administrator</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="subscription"
checked={formData.subscription}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, subscription: checked as boolean }))}
/>
<Label htmlFor="subscription">Subscription</Label>
</div>
</div>
<div className="space-y-2">
<Label>Packs Owned: {selectedUser?.packs.length || 0}</Label>
<div className="text-sm text-muted-foreground">
{selectedUser?.packs.join(', ') || 'No packs owned'}
<Label>Packs Owned</Label>
<div className="border rounded-lg max-h-[200px] overflow-y-auto p-2">
{packsData?.items.length === 0 ? (
<div className="text-sm text-muted-foreground">No packs available</div>
) : (
packsData?.items.map((pack) => (
<div key={pack.id} className="flex items-center space-x-2 py-1">
<Checkbox
id={`pack-${pack.id}`}
checked={formData.packs.includes(pack.id)}
onCheckedChange={(checked) => {
if (checked) {
setFormData(prev => ({
...prev,
packs: [...prev.packs, pack.id],
}))
} else {
setFormData(prev => ({
...prev,
packs: prev.packs.filter(id => id !== pack.id),
}))
}
}}
/>
<Label htmlFor={`pack-${pack.id}`} className="text-sm font-normal cursor-pointer">
{pack.title}
</Label>
</div>
))
)}
</div>
<p className="text-xs text-muted-foreground">
Selected: {formData.packs.length} pack(s)
</p>
</div>
<div className="space-y-2">
<Label>Subscription Features</Label>
<div className="space-y-2">
{['premium', 'unlimited', 'ad_free', 'early_access', 'priority_support'].map((feature) => (
<div key={feature} className="flex items-center space-x-2">
<Checkbox
id={`feature-${feature}`}
checked={formData.subscriptionFeatures.includes(feature)}
onCheckedChange={(checked) => {
if (checked) {
setFormData(prev => ({
...prev,
subscriptionFeatures: [...prev.subscriptionFeatures, feature],
}))
} else {
setFormData(prev => ({
...prev,
subscriptionFeatures: prev.subscriptionFeatures.filter(f => f !== feature),
}))
}
}}
/>
<Label htmlFor={`feature-${feature}`} className="text-sm font-normal cursor-pointer capitalize">
{feature.replace(/_/g, ' ')}
</Label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Selected: {formData.subscriptionFeatures.length} feature(s)
</p>
</div>
</div>
<DialogFooter>

View file

@ -2,6 +2,7 @@
export interface GameCardDto {
id: number
packId?: string
image?: string
mnemo?: string
original?: string

View file

@ -1,16 +1,873 @@
# mnemo_cards_backend
# 🎴 Mnemo Cards Backend
Mnemo backend
Backend сервер для приложения Mnemo Cards - платформы для изучения иностранных слов с использованием мнемотехники.
## Getting Started
## 📋 Оглавление
This project is a starting point for a Flutter application.
- [Технологии](#-технологии)
- [Архитектура](#-архитектура)
- [Быстрый старт](#-быстрый-старт)
- [Структура проекта](#-структура-проекта)
- [API документация](#-api-документация)
- [Разработка](#-разработка)
- [Деплой](#-деплой)
- [Тестирование](#-тестирование)
- [Конфигурация](#-конфигурация)
A few resources to get you started if this is your first Flutter project:
---
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
## 🚀 Технологии
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
### Backend Stack
- **Язык:** Dart 3.8+
- **HTTP сервер:** Shelf
- **База данных:** PostgreSQL 16 + Drift ORM
- **Аутентификация:** JWT (jaguar_jwt)
- **DI:** get_it + injectable
- **API:** RESTful API v2
### Интеграции
- **Платежи:** YooKassa (Российские платежи), Google Play, RuStore
- **Telegram Bot API:** Авторизация через Telegram
- **PostgreSQL:** Production-ready реляционная БД
### DevOps
- **Контейнеризация:** Docker + Docker Compose
- **Deployment:** Coolify (или любой Docker хостинг)
- **CI/CD:** GitHub Actions (опционально)
---
## 🏗️ Архитектура
```
┌─────────────────┐
│ Web Client │
│ (Flutter Web) │
└────────┬────────┘
│ HTTPS
┌─────────────────┐ ┌──────────────┐
│ Mnemo Backend │◄────►│ PostgreSQL │
│ (Dart/Shelf) │ │ Database │
└────────┬────────┘ └──────────────┘
├─► YooKassa API (Payments)
├─► Telegram Bot API (Auth)
├─► Google Play API (IAP)
└─► RuStore API (IAP)
```
### Основные компоненты
- **API Server** (`lib/api/mnemo_shelf.dart`) - HTTP сервер на Shelf
- **Database** (`lib/database/`) - Drift ORM + PostgreSQL
- **Managers** (`lib/user/`, `lib/packs/`, etc.) - Бизнес-логика
- **Cron Jobs** (`lib/cron/`) - Фоновые задачи
- **Auth** (`lib/auth/`, `lib/api/authorize/`) - JWT аутентификация
---
## ⚡ Быстрый старт
### Требования
- **Dart SDK:** 3.8.0+
- **PostgreSQL:** 16+ (локально или Docker)
- **Docker & Docker Compose** (опционально, для PostgreSQL)
### 1. Клонирование репозитория
```bash
git clone <repository-url>
cd mnemo_cards_backend
```
### 2. Установка зависимостей
```bash
# Установка Dart зависимостей
dart pub get
# Генерация кода (Drift, Injectable, JSON)
dart run build_runner build --delete-conflicting-outputs
```
### 3. Настройка PostgreSQL
#### Вариант A: Docker (рекомендуется для разработки)
```bash
# Запустить PostgreSQL через Docker Compose
docker-compose up -d postgres
# Проверить статус
docker-compose ps
```
#### Вариант B: Локальная установка
```bash
# macOS
brew install postgresql@16
brew services start postgresql@16
# Linux
sudo apt install postgresql-16
sudo systemctl start postgresql
```
Создать базу данных:
```sql
CREATE DATABASE mnemo_cards_dev;
CREATE USER mnemo_user WITH PASSWORD 'dev_password';
GRANT ALL PRIVILEGES ON DATABASE mnemo_cards_dev TO mnemo_user;
```
### 4. Настройка переменных окружения
```bash
# Создать .env файл из примера
cp .env.example .env
# Отредактировать .env (указать реальные значения)
nano .env
```
**Минимальная конфигурация** для `.env`:
```bash
# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mnemo_cards_dev
DB_USER=mnemo_user
DB_PASSWORD=dev_password
DB_SSL_MODE=disable
# Backend
PORT=3000
SERVER_ADDRESS=0.0.0.0
WORK_DIR=/root/mnemo_cards_backend
DEBUG=true
# Admin IDs
ADMIN_IDS=1
# JWT Secrets (сгенерировать случайные!)
JWT_SECRET=dev_jwt_secret_change_me_12345
JWT_REFRESH_SECRET=dev_refresh_secret_change_me_12345
```
> ⚠️ **Важно:** Сгенерируйте надежные JWT секреты для продакшена:
> ```bash
> openssl rand -base64 32
> ```
### 5. Запуск сервера
```bash
# Development mode
./run_dev.sh
# Или напрямую
dart run lib/main.dart
```
Сервер запустится на `http://localhost:3000`
### 6. Проверка работоспособности
```bash
# Health check
curl http://localhost:3000/health
# API version info
curl http://localhost:3000/api/v2/health
```
---
## 📁 Структура проекта
```
mnemo_cards_backend/
├── lib/
│ ├── api/ # API endpoints и middleware
│ │ ├── v2/ # RESTful API v2
│ │ │ ├── auth_api_v2.dart # Авторизация
│ │ │ ├── users_api_v2.dart # Пользователи
│ │ │ ├── packs_api_v2.dart # Наборы карточек
│ │ │ ├── tests_api_v2.dart # Тесты
│ │ │ ├── subscriptions_api_v2.dart # Подписки
│ │ │ ├── promocodes_api_v2.dart # Промокоды
│ │ │ ├── admin_*_api_v2.dart # Админ панель
│ │ │ └── ...
│ │ ├── authorize/ # Middleware для авторизации
│ │ ├── purchase/ # Платежи (YooKassa, Google Play, RuStore)
│ │ ├── subscription/ # Управление подписками
│ │ ├── di/ # Dependency Injection (get_it)
│ │ └── mnemo_shelf.dart # Главный HTTP сервер
│ │
│ ├── database/ # PostgreSQL + Drift ORM
│ │ ├── database.dart # Главный класс БД
│ │ ├── tables/ # Определения таблиц
│ │ │ ├── users.dart
│ │ │ ├── packs.dart
│ │ │ ├── auth.dart
│ │ │ ├── payments.dart
│ │ │ └── ...
│ │ └── daos/ # Data Access Objects
│ │ ├── user_dao.dart
│ │ ├── pack_dao.dart
│ │ └── ...
│ │
│ ├── user/ # User management
│ │ └── user_manager.dart
│ │
│ ├── packs/ # Pack management
│ │ ├── pack_manager.dart
│ │ └── free_packs_distributor.dart
│ │
│ ├── tests/ # Test management
│ │ └── test_manager.dart
│ │
│ ├── tasks/ # Task management
│ │ └── task_manager.dart
│ │
│ ├── promo_codes/ # Promo codes
│ │ └── promo_codes_manager.dart
│ │
│ ├── discounts/ # Discounts
│ │ └── discounts_manager.dart
│ │
│ ├── statistics/ # User statistics
│ │ ├── session_tracker.dart
│ │ ├── statistics_calculator.dart
│ │ └── session_tracking_middleware.dart
│ │
│ ├── cron/ # Background jobs
│ │ ├── cron_executor.dart
│ │ ├── check_payment.dart
│ │ ├── backup.dart
│ │ ├── add_free_packs.dart
│ │ └── ...
│ │
│ ├── auth/ # Authentication utilities
│ │ ├── hash.dart
│ │ └── password.dart
│ │
│ └── main.dart # Entry point
├── test/ # Unit & integration tests
│ ├── api/v2/ # API tests
│ ├── database/ # Database tests
│ ├── models/ # Model tests
│ └── statistics/ # Statistics tests
├── data/ # Static data (images, audio)
├── public/ # Public files
├── docs/ # Documentation
├── docker-compose.yml # Docker Compose для разработки
├── Dockerfile # Production Docker image
├── pubspec.yaml # Dart dependencies
├── .env.example # Пример переменных окружения
├── run_dev.sh # Скрипт запуска (dev)
├── run_http_production.sh # Скрипт запуска (prod)
├── build_app.sh # Скрипт сборки
├── codegen.sh # Генерация кода
├── COOLIFY_SETUP.md # Инструкции по деплою в Coolify
├── ENVIRONMENT_VARIABLES.md # Описание env переменных
├── DB_PLAN.md # План миграции БД
└── README.md # Этот файл
```
---
## 🌐 API документация
### Base URL
- **Local:** `http://localhost:3000/api/v2`
- **Production:** `https://your-domain.com/api/v2`
### Авторизация
Backend использует **JWT Bearer tokens** для авторизации:
```http
Authorization: Bearer <access_token>
```
### Основные endpoints
#### Аутентификация
```http
POST /api/v2/auth/register # Регистрация
POST /api/v2/auth/login # Логин
POST /api/v2/auth/refresh # Обновить токен
POST /api/v2/auth/telegram # Авторизация через Telegram
GET /api/v2/auth/telegram/code # Получить код для Telegram
```
#### Пользователи
```http
GET /api/v2/users/me # Текущий пользователь
PATCH /api/v2/users/me # Обновить профиль
GET /api/v2/users/:id # Получить пользователя
GET /api/v2/users/:id/statistics # Статистика пользователя
POST /api/v2/users/:id/balance # Обновить баланс
```
#### Наборы карточек (Packs)
```http
GET /api/v2/packs # Список паков (preview)
GET /api/v2/packs/:id # Детали пака
GET /api/v2/packs/:id/cards # Карточки пака
POST /api/v2/packs/:id/purchase # Купить пак
```
#### Тесты
```http
GET /api/v2/tests # Список тестов
GET /api/v2/tests/:id # Детали теста
POST /api/v2/tests/:id/start # Начать тест
POST /api/v2/tests/:id/submit # Отправить ответы
GET /api/v2/tests/:id/results # Результаты теста
```
#### Подписки
```http
GET /api/v2/subscriptions/plans # Доступные планы
POST /api/v2/subscriptions/purchase # Купить подписку
GET /api/v2/subscriptions/my # Мои подписки
```
#### Промокоды
```http
POST /api/v2/promocodes/apply # Применить промокод
GET /api/v2/promocodes/:code # Проверить промокод
```
#### Админ панель
```http
GET /api/v2/admin/users # Все пользователи
GET /api/v2/admin/packs # Все паки
POST /api/v2/admin/packs # Создать пак
PUT /api/v2/admin/packs/:id # Обновить пак
DELETE /api/v2/admin/packs/:id # Удалить пак
GET /api/v2/admin/analytics # Аналитика
```
### Health Check
```http
GET /health # Health check endpoint
```
**Response:**
```
OK
```
---
## 💻 Разработка
### Генерация кода
Backend использует code generation для:
- **Drift** (database code)
- **Injectable** (dependency injection)
- **Shelf Router** (routing)
- **JSON Serializable** (JSON mapping)
```bash
# Генерация кода
dart run build_runner build --delete-conflicting-outputs
# Watch mode (автоматическая генерация при изменениях)
dart run build_runner watch --delete-conflicting-outputs
# Или использовать скрипт
./codegen.sh
```
### Работа с базой данных
#### Создание новой таблицы
1. Создать файл в `lib/database/tables/`:
```dart
// lib/database/tables/my_table.dart
import 'package:drift/drift.dart';
class MyTables extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
```
2. Добавить таблицу в `lib/database/database.dart`:
```dart
@DriftDatabase(
tables: [
// ... existing tables
MyTables,
],
// ...
)
```
3. Сгенерировать код:
```bash
dart run build_runner build --delete-conflicting-outputs
```
#### Создание DAO
```dart
// lib/database/daos/my_dao.dart
import 'package:drift/drift.dart';
import '../database.dart';
part 'my_dao.g.dart';
@DriftAccessor(tables: [MyTables])
class MyDao extends DatabaseAccessor<AppDatabase> with _$MyDaoMixin {
MyDao(super.db);
Future<List<MyTable>> getAll() => select(myTables).get();
Future<MyTable?> getById(int id) =>
(select(myTables)..where((t) => t.id.equals(id))).getSingleOrNull();
Future<int> create(MyTablesCompanion entry) => into(myTables).insert(entry);
}
```
### Добавление нового API endpoint
1. Создать файл в `lib/api/v2/`:
```dart
// lib/api/v2/my_api_v2.dart
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
class MyApiV2 {
final AppDatabase _db;
MyApiV2(this._db);
Router get router {
final router = Router();
router.get('/my-endpoint', _handleGet);
router.post('/my-endpoint', _handlePost);
return router;
}
Future<Response> _handleGet(Request request) async {
// Implementation
return Response.ok('{"status": "ok"}');
}
Future<Response> _handlePost(Request request) async {
// Implementation
return Response.ok('{"status": "created"}');
}
}
```
2. Зарегистрировать в `lib/api/mnemo_shelf.dart`:
```dart
v2Router.mount('/', getIt.get<MyApiV2>().router);
```
### Cron Jobs
Фоновые задачи запускаются автоматически при старте сервера.
Создание нового cron job:
```dart
// lib/cron/my_task.dart
import 'package:neat_periodic_task/neat_periodic_task.dart';
import 'task.dart';
class MyTask extends Task {
@override
String get name => 'My Task';
@override
Duration get interval => const Duration(hours: 1);
@override
Duration get timeout => const Duration(minutes: 5);
@override
Future<void> run() async {
print('Running my task...');
// Implementation
}
}
```
Добавить в `lib/main.dart`:
```dart
CronManager([
// ... existing tasks
MyTask(),
]).init();
```
### Debugging
```bash
# Запуск с дебагом
DEBUG=true dart run lib/main.dart
# Логи PostgreSQL
docker-compose logs -f postgres
# Логи backend
docker-compose logs -f backend
# Подключение к PostgreSQL
docker-compose exec postgres psql -U mnemo_user -d mnemo_cards_dev
```
---
## 🚢 Деплой
### Docker (рекомендуется)
#### 1. Сборка образа
```bash
# Build Docker image
docker build -t mnemo_backend:latest .
# Или использовать скрипт
./build_app.sh
```
#### 2. Запуск через Docker Compose
```bash
# Production
docker-compose -f docker-compose.prod.yml up -d
# Проверка
docker-compose ps
docker-compose logs -f backend
```
### Coolify (PaaS)
Подробная инструкция по деплою в Coolify: **[COOLIFY_SETUP.md](./COOLIFY_SETUP.md)**
**Краткая версия:**
1. Создать PostgreSQL в Coolify
2. Создать приложение (Dockerfile)
3. Настроить environment variables
4. Deploy
### Переменные окружения для продакшена
Полное описание всех переменных: **[ENVIRONMENT_VARIABLES.md](./ENVIRONMENT_VARIABLES.md)**
**Критически важные:**
```bash
# PostgreSQL (используйте internal hostname в Coolify)
DB_HOST=mnemo-postgres
DB_PORT=5432
DB_NAME=mnemo_cards
DB_USER=mnemo_user
DB_PASSWORD=<сгенерированный_пароль>
DB_SSL_MODE=require
# Backend
PORT=3000
SERVER_ADDRESS=0.0.0.0
DEBUG=false
# JWT (минимум 32 символа!)
JWT_SECRET=<сгенерированный_секрет>
JWT_REFRESH_SECRET=<другой_сгенерированный_секрет>
# Admin IDs
ADMIN_IDS=1,2,3
# YooKassa (если используете)
YOOKASSA_SHOP_ID=<ваш_shop_id>
YOOKASSA_SECRET_KEY=<ваш_secret_key>
```
### Health Checks
Backend предоставляет `/health` endpoint для мониторинга:
```bash
curl https://your-domain.com/health
# Response: OK
```
Настройка в Docker Compose:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
---
## 🧪 Тестирование
### Запуск тестов
```bash
# Все тесты
dart test
# Конкретный файл
dart test test/api/v2/users_api_v2_test.dart
# С coverage
dart test --coverage=coverage
dart pub global activate coverage
format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
```
### Структура тестов
```
test/
├── api/v2/ # API endpoint tests
│ ├── auth_api_v2_test.dart
│ ├── users_api_v2_test.dart
│ ├── packs_api_v2_test.dart
│ └── ...
├── database/ # Database tests
├── models/ # Model tests
└── statistics/ # Statistics tests
```
### Написание тестов
```dart
import 'package:test/test.dart';
import 'package:mockito/mockito.dart';
import 'package:mnemo_cards_backend/database/database.dart';
void main() {
late AppDatabase db;
setUpAll(() async {
// Setup test database
db = AppDatabase.connect(
host: 'localhost',
port: 5433, // Test port
database: 'mnemo_cards_test',
username: 'test_user',
password: 'test_pass',
);
await db.migrator.createAll();
});
tearDownAll(() async {
await db.close();
});
group('UserDao', () {
test('создание пользователя', () async {
// Arrange
final user = UsersCompanion.insert(
name: 'Test User',
externalUserId: 'test123',
);
// Act
final userId = await db.userDao.createUser(user);
final created = await db.userDao.getUserById(userId);
// Assert
expect(created, isNotNull);
expect(created!.name, equals('Test User'));
});
});
}
```
### API тестирование
```bash
# Используйте ./test_api.sh или curl
curl -X POST http://localhost:3000/api/v2/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password123"}'
```
---
## ⚙️ Конфигурация
### Основные переменные окружения
| Переменная | Описание | По умолчанию | Обязательная |
|-----------|----------|--------------|--------------|
| `DB_HOST` | PostgreSQL host | `localhost` | ✅ |
| `DB_PORT` | PostgreSQL port | `5432` | ✅ |
| `DB_NAME` | Database name | `mnemo_cards` | ✅ |
| `DB_USER` | Database user | `mnemo_user` | ✅ |
| `DB_PASSWORD` | Database password | - | ✅ |
| `DB_SSL_MODE` | SSL mode | `disable` | ❌ |
| `PORT` | Backend port | `3000` | ✅ |
| `SERVER_ADDRESS` | Bind address | `0.0.0.0` | ✅ |
| `WORK_DIR` | Working directory | `/app` | ✅ |
| `DEBUG` | Debug mode | `false` | ❌ |
| `JWT_SECRET` | JWT secret | - | ✅ |
| `JWT_REFRESH_SECRET` | Refresh token secret | - | ✅ |
| `ADMIN_IDS` | Admin user IDs | - | ✅ |
| `YOOKASSA_SHOP_ID` | YooKassa shop ID | - | ❌ |
| `YOOKASSA_SECRET_KEY` | YooKassa secret | - | ❌ |
Полное описание: **[ENVIRONMENT_VARIABLES.md](./ENVIRONMENT_VARIABLES.md)**
### Генерация секретов
```bash
# JWT Secret (минимум 32 символа)
openssl rand -base64 32
# Database password
openssl rand -base64 24
```
---
## 📚 Дополнительные ресурсы
### Документация
- **[COOLIFY_SETUP.md](./COOLIFY_SETUP.md)** - Деплой в Coolify
- **[ENVIRONMENT_VARIABLES.md](./ENVIRONMENT_VARIABLES.md)** - Переменные окружения
- **[DB_PLAN.md](./DB_PLAN.md)** - План миграции БД (Isar → PostgreSQL)
### Полезные команды
```bash
# Генерация кода
./codegen.sh
# Запуск dev сервера
./run_dev.sh
# Подключение к PostgreSQL
./connect.sh
# Сборка для продакшена
./build_app.sh
# Тестирование API
./test_api.sh
```
### Troubleshooting
#### Backend не запускается
1. Проверить PostgreSQL:
```bash
docker-compose ps postgres
docker-compose logs postgres
```
2. Проверить переменные окружения:
```bash
cat .env
```
3. Проверить подключение к БД:
```bash
docker-compose exec postgres psql -U mnemo_user -d mnemo_cards_dev
```
#### Ошибка "Connection refused"
- Убедитесь, что PostgreSQL запущен
- Проверьте `DB_HOST` и `DB_PORT`
- В Docker используйте service name (`postgres`), а не `localhost`
#### Ошибка "Invalid JWT secret"
- JWT секрет должен быть минимум 32 символа
- Сгенерируйте новый: `openssl rand -base64 32`
#### Drift генерация не работает
```bash
# Очистить кэш
rm -rf .dart_tool/
dart pub get
dart run build_runner build --delete-conflicting-outputs
```
---
## 🤝 Контрибуция
1. Fork the repository
2. Create feature branch (`git checkout -b feature/amazing-feature`)
3. Commit changes (`git commit -m 'Add amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open Pull Request
### Code Style
- Следуйте [Dart Style Guide](https://dart.dev/guides/language/effective-dart/style)
- Используйте `dart format` перед коммитом
- Пишите unit тесты для новой функциональности
---
## 📝 Лицензия
[Your License Here]
---
## 📧 Контакты
Если возникли вопросы или проблемы, создайте Issue в репозитории.
---
**Made with ❤️ using Dart & PostgreSQL**

View file

@ -95,6 +95,7 @@ class AdminCardsApiV2 {
'image': card.image,
'back': card.back,
'transcription': card.transcription,
'transcriptionMnemo': card.transcriptionMnemo,
'imageBack': card.imageBack,
};
}).toList(),
@ -145,6 +146,7 @@ class AdminCardsApiV2 {
'imageBack': card.imageBack,
'back': card.back,
'transcription': card.transcription,
'transcriptionMnemo': card.transcriptionMnemo,
'createdAt': card.createdAt.dateTime.toIso8601String(),
'updatedAt': card.updatedAt.dateTime.toIso8601String(),
}),
@ -181,6 +183,7 @@ class AdminCardsApiV2 {
imageBack: data['imageBack'] ?? existing.imageBack,
back: data['back'] ?? existing.back,
transcription: data['transcription'] ?? existing.transcription,
transcriptionMnemo: data['transcriptionMnemo'] ?? existing.transcriptionMnemo,
updatedAt: PgDateTime(DateTime.now()),
);
await _db.packDao.updateCard(updated);
@ -198,6 +201,7 @@ class AdminCardsApiV2 {
'imageBack': updated.imageBack,
'back': updated.back,
'transcription': updated.transcription,
'transcriptionMnemo': updated.transcriptionMnemo,
},
}),
headers: {'Content-Type': 'application/json'},
@ -215,6 +219,7 @@ class AdminCardsApiV2 {
imageBack: data['imageBack'] != null ? drift.Value(data['imageBack'] as String) : const drift.Value.absent(),
back: data['back'] != null ? drift.Value(data['back'] as String) : const drift.Value.absent(),
transcription: data['transcription'] != null ? drift.Value(data['transcription'] as String) : const drift.Value.absent(),
transcriptionMnemo: data['transcriptionMnemo'] != null ? drift.Value(data['transcriptionMnemo'] as String) : const drift.Value.absent(),
);
final cardId = await _db.packDao.createCard(companion);
@ -240,6 +245,7 @@ class AdminCardsApiV2 {
'imageBack': created.imageBack,
'back': created.back,
'transcription': created.transcription,
'transcriptionMnemo': created.transcriptionMnemo,
},
}),
headers: {'Content-Type': 'application/json'},
@ -284,6 +290,7 @@ class AdminCardsApiV2 {
imageBack: data['imageBack'] ?? existing.imageBack,
back: data['back'] ?? existing.back,
transcription: data['transcription'] ?? existing.transcription,
transcriptionMnemo: data['transcriptionMnemo'] ?? existing.transcriptionMnemo,
updatedAt: PgDateTime(DateTime.now()),
);

View file

@ -2,99 +2,448 @@
Flutter web приложение для изучения языков с использованием **yx_scope** и **yx_state**.
## 🏗️ Архитектура
[![Version](https://img.shields.io/badge/version-1.0.12+13-blue.svg)](pubspec.yaml)
[![Flutter](https://img.shields.io/badge/Flutter-3.9.2+-02569B?logo=flutter)](https://flutter.dev)
[![Platform](https://img.shields.io/badge/platform-Web-orange.svg)](https://flutter.dev/web)
### Скоупы (yx_scope):
- **AppScope** - корневой скоуп (роутер, аналитика, авторизация)
- **UserScope** - пользовательский скоуп (темы, игры, статистика)
> 🌐 **Важно**: Этот проект предназначен ТОЛЬКО для web платформы и работает на удаленном сервере.
### State Management (yx_state):
- `ThemeStateManager` - управление темой
- `UserStateManager` - состояние пользователя (гость/авторизован)
- `PacksStateManager` - состояние тем/карточек
- `GamesStateManager` - состояние игр
## 📖 Содержание
## 🚀 Быстрый старт
- [Описание](#-описание)
- [Быстрый старт](#-быстрый-старт)
- [Архитектура](#-архитектура)
- [Функциональность](#-функциональность)
- [Технологии](#-технологии)
- [Структура проекта](#-структура-проекта)
- [Разработка](#-разработка)
- [Тестирование](#-тестирование)
- [Деплой](#-деплой)
- [Дополнительная документация](#-дополнительная-документация)
## 🎯 Описание
**mnemo_cards_web_v2** — это современное веб-приложение для изучения иностранных языков, портированное с мобильной версии. Приложение использует карточки (flashcards), мини-игры и систему достижений для эффективного запоминания слов и фраз.
### Ключевые особенности:
- 🎮 Интерактивные игры для запоминания слов
- 📊 Детальная статистика прогресса
- 🎴 Система карточек с озвучкой
- 🌓 Светлая и темная тема
- 🔐 Авторизация через Google и Telegram
- 👤 Гостевой режим
- 💰 Система покупок и подписок
- 🏆 Достижения и задания
- 💬 Встроенный чат
## ⚡ Быстрый старт
### Требования
- Flutter SDK 3.9.2+
- Dart SDK 3.9.2+
- Chrome (для запуска в режиме разработки)
- Backend сервер (см. [mnemo_cards_backend](../mnemo_cards_backend))
### Установка
### Установка зависимостей:
```bash
# 1. Установите зависимости
flutter pub get
```
### Генерация кода (freezed):
```bash
# 2. Сгенерируйте код (freezed, json_serializable)
flutter pub run build_runner build --delete-conflicting-outputs
```
### Запуск приложения:
```bash
# 3. Запустите приложение в Chrome
flutter run -d chrome
```
Полная инструкция: [QUICK_START.md](./QUICK_START.md)
## 🏗️ Архитектура
Приложение построено на базе **Clean Architecture** с использованием **yx_scope** для Dependency Injection и **yx_state** для управления состоянием.
### Скоупы (Dependency Injection)
#### 🌐 AppScope
Корневой скоуп, живет все время работы приложения:
- **Router** (`GoRouter`) — навигация и роутинг
- **Analytics** (`FirebaseAnalytics`) — аналитика событий
- **Auth** (`AuthService`) — авторизация через Google/Telegram
- **Storage** (`SharedPreferences`) — локальное хранилище
#### 👤 UserScope
Пользовательский скоуп, создается при запуске (для гостя или авторизованного пользователя):
- **Packs** — управление темами и карточками
- **Games** — игровая логика
- **Statistics** — статистика прогресса
- **Profile** — профиль пользователя
- **Purchases** — покупки и подписки
- **Tasks** — система заданий
- **Favorites** — избранные карточки
- **Chat** — чат-модуль
> 💡 **Примечание**: UserScope создается сразу при запуске и НЕ удаляется при logout, только меняется состояние.
### State Management (yx_state)
Все состояние приложения управляется через State Managers:
| State Manager | Назначение |
|--------------|-----------|
| `ThemeStateManager` | Управление темой (светлая/темная) |
| `UserStateManager` | Состояние пользователя (гость/авторизован) |
| `PacksStateManager` | Загрузка и управление темами/карточками |
| `GamesStateManager` | Состояние активных игр |
| `StatisticsStateManager` | Статистика и прогресс |
| `ProfileStateManager` | Данные профиля |
| `PurchaseStateManager` | Покупки и подписки |
| `TasksStateManager` | Задания пользователя |
| `FavoritesStateManager` | Избранные карточки |
## ✨ Функциональность
### Реализовано ✅
- 🔐 Авторизация через Google и Telegram
- 👤 Гостевой режим с ограниченным функционалом
- 📚 Просмотр и изучение тем (паков)
- 🎮 Мини-игры для запоминания:
- Сопоставление (Match)
- Ввод букв (Input Letters)
- Выбор ответа (Multiple Choice)
- Матрица слов (Matrix)
- 🎴 Карточки с перелистыванием (Card Flipper)
- 🔊 Озвучка карточек (Text-to-Speech)
- 📊 Детальная статистика:
- График прогресса
- Статистика по словам
- Достижения
- История изучения
- 🌓 Переключение темы (светлая/темная)
- 💰 Система покупок и подписок
- 🏆 Система заданий и достижений
- 💬 Встроенный чат
- 📱 Адаптивный дизайн
### В разработке 🚧
- 📈 Расширенная аналитика
- 🔄 Синхронизация между устройствами
- 🎯 Персонализированные рекомендации
- 🗣️ Дополнительные типы игр
## 🛠 Технологии
### Core
- **Flutter 3.9.2+** — UI фреймворк
- **Dart 3.9.2+** — язык программирования
### Architecture & State Management
- **yx_scope** (1.1.2) — Dependency Injection
- **yx_state** (1.0.0) — State Management
- **go_router** (14.2.0) — Роутинг и навигация
### Backend Integration
- **dio** (5.3.3) — HTTP клиент
- Custom API client для backend
### Firebase
- **firebase_core** (3.3.0) — Core Firebase
- **firebase_auth** (5.3.1) — Авторизация
- **firebase_analytics** (11.2.1) — Аналитика
- **firebase_crashlytics** (4.0.4) — Отчеты о сбоях
- **firebase_remote_config** (5.4.7) — Удаленная конфигурация
### Code Generation
- **freezed** (3.2.3) — Immutable классы и unions
- **json_serializable** (6.8.0) — JSON сериализация
- **build_runner** (2.4.13) — Генерация кода
### UI & UX
- **flutter_screenutil** (5.9.0) — Адаптивная верстка
- **cached_network_image** (3.4.1) — Кэширование изображений
- **shimmer** (3.0.0) — Skeleton loading
- **auto_size_text** (3.0.0) — Автоматический размер текста
- **fl_chart** (0.68.0) — Графики и диаграммы
- **audioplayers** (6.1.0) — Воспроизведение аудио
### Additional
- **shared_preferences** (2.2.3) — Локальное хранилище
- **google_sign_in** (6.2.1) — Google авторизация
- **telegram_web_app** (0.3.3) — Telegram Web App API
- **url_launcher** (6.2.6) — Открытие ссылок
- **package_info_plus** (8.0.0) — Информация о пакете
### Common Packages
- **mnemo_cards_common** — Общие модели и утилиты
- **mnemo_cards_frontend_common** — Общий функционал для фронтенда
- **mnemo_cards_chat** — Чат-модуль
- **payloads_shared**, **bridge_core**, **game_tests** — Игровые модули
## 📁 Структура проекта
```
lib/
├── di/ # Dependency Injection (yx_scope)
│ ├── app_scope/ # AppScope
│ └── user_scope/ # UserScope
├── domain/ # Бизнес-логика
│ ├── services/ # Сервисы
│ └── state/ # State Managers
├── presentation/ # UI слой
│ ├── pages/ # Страницы
│ ├── widgets/ # Виджеты
│ ├── router/ # Роутинг
│ └── theme/ # Темы
├── app.dart # Главный виджет
└── main.dart # Точка входа
mnemo_cards_web_v2/
├── lib/
│ ├── main.dart # Точка входа приложения
│ ├── app.dart # Главный виджет приложения
│ ├── firebase_options.dart # Firebase конфигурация
│ │
│ ├── di/ # Dependency Injection (yx_scope)
│ │ ├── app_scope/ # Корневой скоуп
│ │ │ ├── app_scope.dart
│ │ │ ├── app_scope_container.dart
│ │ │ ├── app_scope_holder.dart
│ │ │ └── modules/ # Модули AppScope
│ │ │ ├── analytics_module.dart
│ │ │ ├── auth_module.dart
│ │ │ ├── router_module.dart
│ │ │ └── storage_module.dart
│ │ │
│ │ └── user_scope/ # Пользовательский скоуп
│ │ ├── user_scope.dart
│ │ ├── user_scope_container.dart
│ │ ├── user_scope_holder.dart
│ │ └── modules/ # Модули UserScope
│ │ ├── packs_module.dart
│ │ ├── games_module.dart
│ │ ├── statistics_module.dart
│ │ ├── profile_module.dart
│ │ ├── purchase_module.dart
│ │ ├── tasks_module.dart
│ │ ├── favorites_module.dart
│ │ ├── chat_module.dart
│ │ └── ...
│ │
│ ├── domain/ # Бизнес-логика
│ │ ├── services/ # Сервисы
│ │ │ ├── api_service.dart
│ │ │ ├── auth_service.dart
│ │ │ ├── analytics_service.dart
│ │ │ └── ...
│ │ │
│ │ └── state/ # State Managers
│ │ ├── theme_state_manager.dart
│ │ ├── user_state_manager.dart
│ │ ├── packs_state_manager.dart
│ │ ├── games_state_manager.dart
│ │ └── ...
│ │
│ ├── presentation/ # UI слой
│ │ ├── router/ # Роутинг
│ │ │ └── app_router.dart
│ │ │
│ │ ├── theme/ # Темы оформления
│ │ │ ├── app_theme.dart
│ │ │ └── app_colors.dart
│ │ │
│ │ ├── pages/ # Страницы приложения
│ │ │ ├── auth/ # Авторизация
│ │ │ ├── home/ # Главная (темы)
│ │ │ ├── games/ # Список игр
│ │ │ ├── game/ # Игровая страница
│ │ │ ├── pack_details/ # Детали темы
│ │ │ ├── statistics/ # Статистика
│ │ │ ├── profile/ # Профиль
│ │ │ ├── purchase/ # Покупки
│ │ │ ├── tasks/ # Задания
│ │ │ └── test/ # Тестирование
│ │ │
│ │ └── widgets/ # Переиспользуемые виджеты
│ │ ├── main_shell.dart # Основной shell с навигацией
│ │ ├── pack_card.dart # Карточка темы
│ │ ├── game_card.dart # Карточка игры
│ │ ├── card_flipper/ # Flipper карточек
│ │ ├── game/ # Игровые виджеты
│ │ ├── loading/ # Shimmer загрузки
│ │ └── ...
│ │
│ └── utils/ # Утилиты
│ ├── responsive.dart
│ ├── color_extension.dart
│ └── ...
├── test/ # Тесты
│ ├── unit/ # Unit тесты
│ ├── widget/ # Widget тесты
│ └── integration/ # Интеграционные тесты
├── web/ # Web-специфичные файлы
│ ├── index.html
│ ├── manifest.json
│ └── icons/
├── deploy/ # Конфигурация деплоя
│ ├── nginx.conf
│ └── README.md
├── icons/ # Иконки приложения
├── fonts/ # Шрифты (Nunito)
├── pubspec.yaml # Зависимости
├── analysis_options.yaml # Lint правила
├── README.md # Этот файл
├── QUICK_START.md # Быстрый старт
├── project_config.md # Конфигурация проекта
└── open_api.yaml # API спецификация
```
## 🎯 Основные функции
## 🔧 Разработка
- ✅ Авторизация через Google и Telegram
- ✅ Гостевой режим
- ✅ 3 вкладки: Темы, Игры, Профиль
- ✅ Светлая/темная тема
- 🚧 Изучение карточек (TODO)
- 🚧 Мини-игры (TODO)
- 🚧 Статистика (TODO)
### Генерация кода
## 📚 Технологии
При изменении классов с аннотациями `@freezed`, `@JsonSerializable` и т.д.:
- Flutter Web
- yx_scope / yx_state - DI и state management
- go_router - роутинг
- Firebase - аналитика, авторизация
- freezed - code generation
- dio - HTTP клиент
## 📖 Документация
См. [PLAN.md](./PLAN.md) для детального плана разработки.
## 🔧 Настройка Firebase
1. Установите Firebase CLI:
```bash
# Одноразовая генерация
flutter pub run build_runner build --delete-conflicting-outputs
# Автоматическая генерация при изменениях
flutter pub run build_runner watch --delete-conflicting-outputs
```
### Работа с Firebase
```bash
# 1. Установите Firebase CLI
npm install -g firebase-tools
```
2. Настройте Firebase проект:
```bash
# 2. Авторизуйтесь
firebase login
# 3. Настройте проект
flutterfire configure
```
3. Это обновит `lib/firebase_options.dart` с правильными конфигурациями.
### Линтинг
## ⚠️ Важные примечания
```bash
# Анализ кода
flutter analyze
- **Web Only**: Этот проект предназначен ТОЛЬКО для web платформы
- UserScope создается сразу при запуске (для гостевого режима)
- При logout UserScope НЕ удаляется, только меняется состояние
- Используйте `flutter pub run build_runner watch` для автоматической генерации кода
# Исправление форматирования
dart format lib/ test/ -l 80
```
## 📝 TODO
### Отладка
См. текущие задачи в [PLAN.md](./PLAN.md) раздел "Этапы разработки".
```bash
# Запуск с hot reload
flutter run -d chrome
# Запуск с DevTools
flutter run -d chrome --observatory-port=8888
# Запуск с verbose логированием
flutter run -d chrome -v
```
### API Integration
API сервера описано в `open_api.yaml`. Backend должен быть запущен локально или доступен по URL.
```bash
# Локальный backend
cd ../mnemo_cards_backend
./run_dev.sh
```
## 🧪 Тестирование
### Запуск тестов
```bash
# Все тесты
flutter test
# Unit тесты
flutter test test/unit/
# Widget тесты
flutter test test/widget/
# С покрытием
flutter test --coverage
# Конкретный тест
flutter test test/unit/domain/state/theme_state_manager_test.dart
```
### Правила тестирования
- ✅ Используйте `yx_state` и `yx_scope` в тестах
- ✅ Следуйте Clean Architecture
- ✅ Модулизируйте код и разбивайте на файлы
- ✅ Пишите unit-тесты для всех функциональностей
- ✅ Используйте `mocktail` для мокирования
См. также: [.cursor/rules/write-tests.mdc](.cursor/rules/write-tests.mdc)
## 🚀 Деплой
### Автоматический деплой
```bash
# Из корня проекта mnemo_cards_web_v2
./deploy.sh
```
Скрипт деплоя:
1. ✅ Собирает Flutter web app с оптимизацией `-O4`
2. ✅ Загружает на сервер `147.45.152.129`
3. ✅ Настраивает Nginx для домена `mnemo-cards.online`
4. ✅ Настраивает SSL сертификаты (Let's Encrypt)
5. ✅ Настраивает firewall
### Ручной деплой
```bash
# 1. Соберите приложение
flutter build web -O4 --release
# 2. Загрузите на сервер
rsync -avz --delete build/web/ user@server:/var/www/mnemo-cards/
# 3. Настройте Nginx (см. deploy/nginx.conf)
```
Подробнее: [deploy/README.md](./deploy/README.md)
## 📚 Дополнительная документация
### Внутренняя документация
- [QUICK_START.md](./QUICK_START.md) — Быстрый старт за 2 команды
- [project_config.md](./project_config.md) — Конфигурация проекта
- [deploy/README.md](./deploy/README.md) — Инструкция по деплою
### Backend
- [mnemo_cards_backend/README.md](../mnemo_cards_backend/README.md) — Backend документация
- `open_api.yaml` — API спецификация
### Правила разработки
- [.cursor/rules/write-tests.mdc](.cursor/rules/write-tests.mdc) — Правила написания тестов
- [.cursor/rules/mnemo-cards-web.mdc](../.cursor/rules/mnemo-cards-web.mdc) — Правила разработки веб-приложения
## 🔗 Полезные ссылки
- [Flutter Documentation](https://docs.flutter.dev/)
- [yx_scope Documentation](packages/yx/city-services-pub/yx_scope/README.md)
- [yx_state Documentation](packages/yx/city-services-pub/yx_state/README.md)
- [GoRouter Documentation](https://pub.dev/packages/go_router)
- [Freezed Documentation](https://pub.dev/packages/freezed)
## 📄 Лицензия
Proprietary - все права защищены.
## 👨‍💻 Автор
Dmitry - [mnemo-cards.online](https://mnemo-cards.online)
---
<div align="center">
**mnemo_cards_web_v2** | Made with ❤️ using Flutter
</div>

View file

@ -84,7 +84,7 @@ class _AppInitializerState extends State<_AppInitializer> {
// Set authenticated user
final userScope = widget.appScope.userScopeHolder.scope;
if (userScope != null) {
userScope.userStateManager.setUser(user);
await userScope.userStateManager.setUser(user);
// Notify router about auth change
widget.appScope.userScopeHolder.notifyAuthChanged();
}
@ -131,7 +131,7 @@ class _AppInitializerState extends State<_AppInitializer> {
if (user != null) {
log('Auto-login successful, creating UserScope', name: 'App');
// Create UserScope only for authenticated users
widget.appScope.userScopeHolder.scope!.userStateManager.setUser(user);
await widget.appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Notify router about auth change
widget.appScope.userScopeHolder.notifyAuthChanged();
log('UserScope created and user set', name: 'App');

View file

@ -26,7 +26,7 @@ class UserStateManager extends StateManager<UserState> {
UserStateManager() : super(const UserState.guest());
/// Устанавливает авторизованного пользователя
void setUser(UserDto user) => handle((emit) async {
Future<void> setUser(UserDto user) => handle((emit) async {
emit(UserState.authenticated(user: user));
});

View file

@ -55,8 +55,8 @@ class _AuthPageState extends State<AuthPage> {
await appScope.userScopeHolder.create();
}
// Update user state
appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Update user state and wait for it to complete
await appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Notify that UserScope has changed
appScope.notifyUserScopeChanged();
@ -65,6 +65,14 @@ class _AuthPageState extends State<AuthPage> {
appScope.userScopeHolder.notifyAuthChanged();
// Router will automatically redirect to /home when authentication state changes
// Force router refresh to ensure redirect happens
if (mounted) {
// Small delay to ensure state is fully updated
await Future<void>.delayed(const Duration(milliseconds: 50));
if (mounted && appScope.userScopeHolder.isAuthenticated) {
router.go('/home');
}
}
} else {
setState(() {
_errorMessage = 'Login cancelled';

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
@ -92,8 +93,8 @@ class _SignInWithTelegramState extends State<SignInWithTelegram> {
await appScope.userScopeHolder.create();
}
// Update user state
appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Update user state and wait for it to complete
await appScope.userScopeHolder.scope!.userStateManager.setUser(user);
// Notify that UserScope has changed
appScope.notifyUserScopeChanged();
@ -101,6 +102,16 @@ class _SignInWithTelegramState extends State<SignInWithTelegram> {
// Notify router about auth change
appScope.userScopeHolder.notifyAuthChanged();
// Force router refresh to ensure redirect happens
if (mounted) {
// Small delay to ensure state is fully updated
await Future<void>.delayed(const Duration(milliseconds: 50));
if (mounted && appScope.userScopeHolder.isAuthenticated) {
final router = GoRouter.of(context);
router.go('/home');
}
}
widget.onLoginSuccess();
} else {
widget.onError('Неверный код авторизации');