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
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:
parent
50e7e75a56
commit
889784e11f
13 changed files with 2149 additions and 104 deletions
356
mnemo_cards_admin/src/components/BulkCardEditor.tsx
Normal file
356
mnemo_cards_admin/src/components/BulkCardEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
mnemo_cards_admin/src/components/BulkCardUpload.tsx
Normal file
248
mnemo_cards_admin/src/components/BulkCardUpload.tsx
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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,10 +234,16 @@ export default function CardsPage() {
|
|||
View, create, edit and delete game cards
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Card
|
||||
</Button>
|
||||
<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 */}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,14 +509,39 @@ 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
|
||||
id="price"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||||
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="price">Price</Label>
|
||||
<Label htmlFor="size">Size</Label>
|
||||
<Input
|
||||
id="price"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||||
placeholder="Free, $1.99, etc."
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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,20 +365,93 @@ export default function UsersPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="admin"
|
||||
checked={formData.admin}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="admin">Administrator</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="admin"
|
||||
checked={formData.admin}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
export interface GameCardDto {
|
||||
id: number
|
||||
packId?: string
|
||||
image?: string
|
||||
mnemo?: string
|
||||
original?: string
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,99 +2,448 @@
|
|||
|
||||
Flutter web приложение для изучения языков с использованием **yx_scope** и **yx_state**.
|
||||
|
||||
## 🏗️ Архитектура
|
||||
[](pubspec.yaml)
|
||||
[](https://flutter.dev)
|
||||
[](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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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('Неверный код авторизации');
|
||||
|
|
|
|||
Loading…
Reference in a new issue