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 { Textarea } from '@/components/ui/textarea'
|
||||||
import { ImageUpload } from '@/components/ui/image-upload'
|
import { ImageUpload } from '@/components/ui/image-upload'
|
||||||
import { CardVoicesManager } from '@/components/CardVoicesManager'
|
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() {
|
export default function CardsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -47,9 +51,18 @@ export default function CardsPage() {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
const [cardToDelete, setCardToDelete] = useState<GameCardDto | null>(null)
|
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
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
packId: '',
|
||||||
original: '',
|
original: '',
|
||||||
translation: '',
|
translation: '',
|
||||||
mnemo: '',
|
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 = () => {
|
const openCreateDialog = () => {
|
||||||
setSelectedCard(null)
|
setSelectedCard(null)
|
||||||
setFormData({
|
setFormData({
|
||||||
|
packId: '',
|
||||||
original: '',
|
original: '',
|
||||||
translation: '',
|
translation: '',
|
||||||
mnemo: '',
|
mnemo: '',
|
||||||
|
|
@ -127,6 +147,7 @@ export default function CardsPage() {
|
||||||
const openEditDialog = (card: GameCardDto) => {
|
const openEditDialog = (card: GameCardDto) => {
|
||||||
setSelectedCard(card)
|
setSelectedCard(card)
|
||||||
setFormData({
|
setFormData({
|
||||||
|
packId: (card as any).packId || '',
|
||||||
original: card.original || '',
|
original: card.original || '',
|
||||||
translation: card.translation || '',
|
translation: card.translation || '',
|
||||||
mnemo: card.mnemo || '',
|
mnemo: card.mnemo || '',
|
||||||
|
|
@ -154,6 +175,7 @@ export default function CardsPage() {
|
||||||
|
|
||||||
const cardData: GameCardDto = {
|
const cardData: GameCardDto = {
|
||||||
id: selectedCard?.id || -1,
|
id: selectedCard?.id || -1,
|
||||||
|
packId: formData.packId.trim() || undefined,
|
||||||
original: formData.original.trim(),
|
original: formData.original.trim(),
|
||||||
translation: formData.translation.trim(),
|
translation: formData.translation.trim(),
|
||||||
mnemo: formData.mnemo.trim(),
|
mnemo: formData.mnemo.trim(),
|
||||||
|
|
@ -212,10 +234,16 @@ export default function CardsPage() {
|
||||||
View, create, edit and delete game cards
|
View, create, edit and delete game cards
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreateDialog}>
|
<div className="flex items-center space-x-2">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Button variant="outline" onClick={() => setIsBulkUploadOpen(true)}>
|
||||||
Add Card
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Bulk Upload
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|
@ -335,6 +363,26 @@ export default function CardsPage() {
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="original">Original *</Label>
|
<Label htmlFor="original">Original *</Label>
|
||||||
|
|
@ -462,6 +510,46 @@ export default function CardsPage() {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ export default function PacksPage() {
|
||||||
appStoreId: '',
|
appStoreId: '',
|
||||||
price: '',
|
price: '',
|
||||||
order: 0,
|
order: 0,
|
||||||
|
version: '',
|
||||||
|
size: 0,
|
||||||
cover: undefined as string | undefined,
|
cover: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -132,6 +134,8 @@ export default function PacksPage() {
|
||||||
appStoreId: '',
|
appStoreId: '',
|
||||||
price: '',
|
price: '',
|
||||||
order: 0,
|
order: 0,
|
||||||
|
version: '',
|
||||||
|
size: 0,
|
||||||
cover: undefined,
|
cover: undefined,
|
||||||
})
|
})
|
||||||
setCurrentCardIds([])
|
setCurrentCardIds([])
|
||||||
|
|
@ -155,6 +159,8 @@ export default function PacksPage() {
|
||||||
appStoreId: fullPack.appStoreId || '',
|
appStoreId: fullPack.appStoreId || '',
|
||||||
price: fullPack.price || '',
|
price: fullPack.price || '',
|
||||||
order: fullPack.order || 0,
|
order: fullPack.order || 0,
|
||||||
|
version: fullPack.version || '',
|
||||||
|
size: fullPack.size || 0,
|
||||||
cover: fullPack.cover,
|
cover: fullPack.cover,
|
||||||
})
|
})
|
||||||
// Initialize current card IDs from addCardIds (which contains all cards in pack)
|
// 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,
|
appStoreId: formData.appStoreId.trim() || undefined,
|
||||||
price: formData.price.trim() || undefined,
|
price: formData.price.trim() || undefined,
|
||||||
order: formData.order,
|
order: formData.order,
|
||||||
|
version: formData.version.trim() || undefined,
|
||||||
|
size: formData.size > 0 ? formData.size : undefined,
|
||||||
cover: formData.cover || undefined,
|
cover: formData.cover || undefined,
|
||||||
addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
|
addCardIds: cardsToAdd.length > 0 ? cardsToAdd : undefined,
|
||||||
removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
|
removeCardIds: cardsToRemove.length > 0 ? cardsToRemove : undefined,
|
||||||
|
|
@ -501,14 +509,39 @@ export default function PacksPage() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="price">Price</Label>
|
<Label htmlFor="size">Size</Label>
|
||||||
<Input
|
<Input
|
||||||
id="price"
|
id="size"
|
||||||
value={formData.price}
|
type="number"
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
value={formData.size}
|
||||||
placeholder="Free, $1.99, etc."
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { packsApi } from '@/api/packs'
|
||||||
import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
|
import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
|
|
@ -54,6 +55,9 @@ export default function UsersPage() {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
admin: false,
|
admin: false,
|
||||||
|
subscription: false,
|
||||||
|
packs: [] as string[],
|
||||||
|
subscriptionFeatures: [] as string[],
|
||||||
})
|
})
|
||||||
|
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
@ -64,6 +68,12 @@ export default function UsersPage() {
|
||||||
queryFn: () => usersApi.getUsers({ page, limit }),
|
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
|
// Mutations
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (user: UserDto) => usersApi.upsertUser(user),
|
mutationFn: (user: UserDto) => usersApi.upsertUser(user),
|
||||||
|
|
@ -98,6 +108,9 @@ export default function UsersPage() {
|
||||||
name: user.name || '',
|
name: user.name || '',
|
||||||
email: user.email || '',
|
email: user.email || '',
|
||||||
admin: user.admin,
|
admin: user.admin,
|
||||||
|
subscription: user.subscription || false,
|
||||||
|
packs: [...user.packs],
|
||||||
|
subscriptionFeatures: [...user.subscriptionFeatures],
|
||||||
})
|
})
|
||||||
setIsEditDialogOpen(true)
|
setIsEditDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
@ -150,9 +163,10 @@ export default function UsersPage() {
|
||||||
name: formData.name.trim() || undefined,
|
name: formData.name.trim() || undefined,
|
||||||
email: formData.email.trim() || undefined,
|
email: formData.email.trim() || undefined,
|
||||||
admin: formData.admin,
|
admin: formData.admin,
|
||||||
packs: selectedUser.packs,
|
subscription: formData.subscription,
|
||||||
|
packs: formData.packs,
|
||||||
purchases: selectedUser.purchases,
|
purchases: selectedUser.purchases,
|
||||||
subscriptionFeatures: selectedUser.subscriptionFeatures,
|
subscriptionFeatures: formData.subscriptionFeatures,
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMutation.mutate(userData)
|
updateMutation.mutate(userData)
|
||||||
|
|
@ -351,20 +365,93 @@ export default function UsersPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Checkbox
|
<div className="flex items-center space-x-2">
|
||||||
id="admin"
|
<Checkbox
|
||||||
checked={formData.admin}
|
id="admin"
|
||||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
|
checked={formData.admin}
|
||||||
/>
|
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
|
||||||
<Label htmlFor="admin">Administrator</Label>
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Packs Owned: {selectedUser?.packs.length || 0}</Label>
|
<Label>Packs Owned</Label>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="border rounded-lg max-h-[200px] overflow-y-auto p-2">
|
||||||
{selectedUser?.packs.join(', ') || 'No packs owned'}
|
{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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
export interface GameCardDto {
|
export interface GameCardDto {
|
||||||
id: number
|
id: number
|
||||||
|
packId?: string
|
||||||
image?: string
|
image?: string
|
||||||
mnemo?: string
|
mnemo?: string
|
||||||
original?: 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
|
### Backend Stack
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
- **Язык:** Dart 3.8+
|
||||||
samples, guidance on mobile development, and a full API reference.
|
- **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,
|
'image': card.image,
|
||||||
'back': card.back,
|
'back': card.back,
|
||||||
'transcription': card.transcription,
|
'transcription': card.transcription,
|
||||||
|
'transcriptionMnemo': card.transcriptionMnemo,
|
||||||
'imageBack': card.imageBack,
|
'imageBack': card.imageBack,
|
||||||
};
|
};
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|
@ -145,6 +146,7 @@ class AdminCardsApiV2 {
|
||||||
'imageBack': card.imageBack,
|
'imageBack': card.imageBack,
|
||||||
'back': card.back,
|
'back': card.back,
|
||||||
'transcription': card.transcription,
|
'transcription': card.transcription,
|
||||||
|
'transcriptionMnemo': card.transcriptionMnemo,
|
||||||
'createdAt': card.createdAt.dateTime.toIso8601String(),
|
'createdAt': card.createdAt.dateTime.toIso8601String(),
|
||||||
'updatedAt': card.updatedAt.dateTime.toIso8601String(),
|
'updatedAt': card.updatedAt.dateTime.toIso8601String(),
|
||||||
}),
|
}),
|
||||||
|
|
@ -181,6 +183,7 @@ class AdminCardsApiV2 {
|
||||||
imageBack: data['imageBack'] ?? existing.imageBack,
|
imageBack: data['imageBack'] ?? existing.imageBack,
|
||||||
back: data['back'] ?? existing.back,
|
back: data['back'] ?? existing.back,
|
||||||
transcription: data['transcription'] ?? existing.transcription,
|
transcription: data['transcription'] ?? existing.transcription,
|
||||||
|
transcriptionMnemo: data['transcriptionMnemo'] ?? existing.transcriptionMnemo,
|
||||||
updatedAt: PgDateTime(DateTime.now()),
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
);
|
);
|
||||||
await _db.packDao.updateCard(updated);
|
await _db.packDao.updateCard(updated);
|
||||||
|
|
@ -198,6 +201,7 @@ class AdminCardsApiV2 {
|
||||||
'imageBack': updated.imageBack,
|
'imageBack': updated.imageBack,
|
||||||
'back': updated.back,
|
'back': updated.back,
|
||||||
'transcription': updated.transcription,
|
'transcription': updated.transcription,
|
||||||
|
'transcriptionMnemo': updated.transcriptionMnemo,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
headers: {'Content-Type': 'application/json'},
|
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(),
|
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(),
|
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(),
|
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);
|
final cardId = await _db.packDao.createCard(companion);
|
||||||
|
|
@ -240,6 +245,7 @@ class AdminCardsApiV2 {
|
||||||
'imageBack': created.imageBack,
|
'imageBack': created.imageBack,
|
||||||
'back': created.back,
|
'back': created.back,
|
||||||
'transcription': created.transcription,
|
'transcription': created.transcription,
|
||||||
|
'transcriptionMnemo': created.transcriptionMnemo,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
|
@ -284,6 +290,7 @@ class AdminCardsApiV2 {
|
||||||
imageBack: data['imageBack'] ?? existing.imageBack,
|
imageBack: data['imageBack'] ?? existing.imageBack,
|
||||||
back: data['back'] ?? existing.back,
|
back: data['back'] ?? existing.back,
|
||||||
transcription: data['transcription'] ?? existing.transcription,
|
transcription: data['transcription'] ?? existing.transcription,
|
||||||
|
transcriptionMnemo: data['transcriptionMnemo'] ?? existing.transcriptionMnemo,
|
||||||
updatedAt: PgDateTime(DateTime.now()),
|
updatedAt: PgDateTime(DateTime.now()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,99 +2,448 @@
|
||||||
|
|
||||||
Flutter web приложение для изучения языков с использованием **yx_scope** и **yx_state**.
|
Flutter web приложение для изучения языков с использованием **yx_scope** и **yx_state**.
|
||||||
|
|
||||||
## 🏗️ Архитектура
|
[](pubspec.yaml)
|
||||||
|
[](https://flutter.dev)
|
||||||
|
[](https://flutter.dev/web)
|
||||||
|
|
||||||
### Скоупы (yx_scope):
|
> 🌐 **Важно**: Этот проект предназначен ТОЛЬКО для web платформы и работает на удаленном сервере.
|
||||||
- **AppScope** - корневой скоуп (роутер, аналитика, авторизация)
|
|
||||||
- **UserScope** - пользовательский скоуп (темы, игры, статистика)
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
|
# 1. Установите зависимости
|
||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
|
||||||
|
|
||||||
### Генерация кода (freezed):
|
# 2. Сгенерируйте код (freezed, json_serializable)
|
||||||
```bash
|
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск приложения:
|
# 3. Запустите приложение в Chrome
|
||||||
```bash
|
|
||||||
flutter run -d 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/
|
mnemo_cards_web_v2/
|
||||||
├── di/ # Dependency Injection (yx_scope)
|
│
|
||||||
│ ├── app_scope/ # AppScope
|
├── lib/
|
||||||
│ └── user_scope/ # UserScope
|
│ ├── main.dart # Точка входа приложения
|
||||||
├── domain/ # Бизнес-логика
|
│ ├── app.dart # Главный виджет приложения
|
||||||
│ ├── services/ # Сервисы
|
│ ├── firebase_options.dart # Firebase конфигурация
|
||||||
│ └── state/ # State Managers
|
│ │
|
||||||
├── presentation/ # UI слой
|
│ ├── di/ # Dependency Injection (yx_scope)
|
||||||
│ ├── pages/ # Страницы
|
│ │ ├── app_scope/ # Корневой скоуп
|
||||||
│ ├── widgets/ # Виджеты
|
│ │ │ ├── app_scope.dart
|
||||||
│ ├── router/ # Роутинг
|
│ │ │ ├── app_scope_container.dart
|
||||||
│ └── theme/ # Темы
|
│ │ │ ├── app_scope_holder.dart
|
||||||
├── app.dart # Главный виджет
|
│ │ │ └── modules/ # Модули AppScope
|
||||||
└── main.dart # Точка входа
|
│ │ │ ├── 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
|
```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
|
npm install -g firebase-tools
|
||||||
```
|
|
||||||
|
|
||||||
2. Настройте Firebase проект:
|
# 2. Авторизуйтесь
|
||||||
```bash
|
|
||||||
firebase login
|
firebase login
|
||||||
|
|
||||||
|
# 3. Настройте проект
|
||||||
flutterfire configure
|
flutterfire configure
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Это обновит `lib/firebase_options.dart` с правильными конфигурациями.
|
### Линтинг
|
||||||
|
|
||||||
## ⚠️ Важные примечания
|
```bash
|
||||||
|
# Анализ кода
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
- **Web Only**: Этот проект предназначен ТОЛЬКО для web платформы
|
# Исправление форматирования
|
||||||
- UserScope создается сразу при запуске (для гостевого режима)
|
dart format lib/ test/ -l 80
|
||||||
- При logout UserScope НЕ удаляется, только меняется состояние
|
```
|
||||||
- Используйте `flutter pub run build_runner watch` для автоматической генерации кода
|
|
||||||
|
|
||||||
## 📝 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
|
// Set authenticated user
|
||||||
final userScope = widget.appScope.userScopeHolder.scope;
|
final userScope = widget.appScope.userScopeHolder.scope;
|
||||||
if (userScope != null) {
|
if (userScope != null) {
|
||||||
userScope.userStateManager.setUser(user);
|
await userScope.userStateManager.setUser(user);
|
||||||
// Notify router about auth change
|
// Notify router about auth change
|
||||||
widget.appScope.userScopeHolder.notifyAuthChanged();
|
widget.appScope.userScopeHolder.notifyAuthChanged();
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +131,7 @@ class _AppInitializerState extends State<_AppInitializer> {
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
log('Auto-login successful, creating UserScope', name: 'App');
|
log('Auto-login successful, creating UserScope', name: 'App');
|
||||||
// Create UserScope only for authenticated users
|
// 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
|
// Notify router about auth change
|
||||||
widget.appScope.userScopeHolder.notifyAuthChanged();
|
widget.appScope.userScopeHolder.notifyAuthChanged();
|
||||||
log('UserScope created and user set', name: 'App');
|
log('UserScope created and user set', name: 'App');
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class UserStateManager extends StateManager<UserState> {
|
||||||
UserStateManager() : super(const UserState.guest());
|
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));
|
emit(UserState.authenticated(user: user));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ class _AuthPageState extends State<AuthPage> {
|
||||||
await appScope.userScopeHolder.create();
|
await appScope.userScopeHolder.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user state
|
// Update user state and wait for it to complete
|
||||||
appScope.userScopeHolder.scope!.userStateManager.setUser(user);
|
await appScope.userScopeHolder.scope!.userStateManager.setUser(user);
|
||||||
|
|
||||||
// Notify that UserScope has changed
|
// Notify that UserScope has changed
|
||||||
appScope.notifyUserScopeChanged();
|
appScope.notifyUserScopeChanged();
|
||||||
|
|
@ -65,6 +65,14 @@ class _AuthPageState extends State<AuthPage> {
|
||||||
appScope.userScopeHolder.notifyAuthChanged();
|
appScope.userScopeHolder.notifyAuthChanged();
|
||||||
|
|
||||||
// Router will automatically redirect to /home when authentication state changes
|
// 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 {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = 'Login cancelled';
|
_errorMessage = 'Login cancelled';
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||||
|
|
@ -92,8 +93,8 @@ class _SignInWithTelegramState extends State<SignInWithTelegram> {
|
||||||
await appScope.userScopeHolder.create();
|
await appScope.userScopeHolder.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user state
|
// Update user state and wait for it to complete
|
||||||
appScope.userScopeHolder.scope!.userStateManager.setUser(user);
|
await appScope.userScopeHolder.scope!.userStateManager.setUser(user);
|
||||||
|
|
||||||
// Notify that UserScope has changed
|
// Notify that UserScope has changed
|
||||||
appScope.notifyUserScopeChanged();
|
appScope.notifyUserScopeChanged();
|
||||||
|
|
@ -101,6 +102,16 @@ class _SignInWithTelegramState extends State<SignInWithTelegram> {
|
||||||
// Notify router about auth change
|
// Notify router about auth change
|
||||||
appScope.userScopeHolder.notifyAuthChanged();
|
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();
|
widget.onLoginSuccess();
|
||||||
} else {
|
} else {
|
||||||
widget.onError('Неверный код авторизации');
|
widget.onError('Неверный код авторизации');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue