dockerfiles
Some checks are pending
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
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run

This commit is contained in:
Dmitry 2025-12-11 05:04:03 +03:00
parent 7295a15759
commit 1e424031dd
79 changed files with 158 additions and 3137 deletions

View file

@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
# --- Build stage ------------------------------------------------------------
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files for dependency caching
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# Verify build output
RUN ls -la dist/ && test -f dist/index.html || (echo "ERROR: index.html not found!" && exit 1)
# --- Runtime stage ----------------------------------------------------------
FROM nginx:1.27-alpine AS runtime
# Install wget for healthcheck
RUN apk add --no-cache wget
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built app
COPY --from=build /app/dist/ /usr/share/nginx/html/
# Verify files were copied
RUN ls -la /usr/share/nginx/html/ && test -f /usr/share/nginx/html/index.html || (echo "ERROR: index.html not copied!" && exit 1)
EXPOSE 80
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1

View file

@ -0,0 +1,41 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
# Security headers (enhanced for admin panel)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1h;
add_header Cache-Control "private, must-revalidate";
}
# React SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
# Security - deny access to hidden files
location ~ /\. {
deny all;
}
}

View file

@ -1,332 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:mnemo_cards/domain/router/dialogs.dart';
import 'package:universal_io/io.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../main.dart';
class EditCard {
GameCardDto gameCardDto;
AdminApi adminApi;
bool imageChanged = false;
EditCard(GameCardDto? cardDto, this.adminApi)
: gameCardDto =
cardDto ??
const GameCardDto(
id: -1,
image: null,
mnemo: null,
original: null,
translation: null,
transcription: null,
transcriptionMnemo: null,
imageBack: null,
back: null,
);
void _saveCard() async {
final dto = imageChanged ? gameCardDto : gameCardDto.copyWith.image(null);
final _progress = ValueNotifier<(int, int)>((0, 1));
final _dialogCompleter = Completer();
showInfoDialog(
'',
child: ValueListenableBuilder(
valueListenable: _progress,
builder: (c, v, _) => Text(
(v.$1 == v.$2 || v.$2 < 0)
? 'Waiting for response...'
: 'Uploading ${v.$1}/${v.$2}...',
),
),
completer: _dialogCompleter,
);
final result = await adminApi.addCard(
dto,
progressCallback: (a, b) => _progress.value = (a, b),
);
_dialogCompleter.complete();
if (!result) {
ScaffoldMessenger.of(
scaffoldKey.currentContext!,
).showSnackBar(SnackBar(content: Text('ERROR')));
} else {
ScaffoldMessenger.of(
scaffoldKey.currentContext!,
).showSnackBar(SnackBar(content: Text('Success!!!')));
appRouter.maybePop();
}
}
void _deleteCard() async {
final result = await adminApi.deleteCard(gameCardDto);
if (!result) {
ScaffoldMessenger.of(
scaffoldKey.currentContext!,
).showSnackBar(SnackBar(content: Text('ERROR')));
} else {
ScaffoldMessenger.of(
scaffoldKey.currentContext!,
).showSnackBar(SnackBar(content: Text('Success!!!')));
appRouter.maybePop();
}
}
void addCard(BuildContext context) {
final theme = Theme.of(context).textTheme;
final TextEditingController originalController = TextEditingController(
text: gameCardDto.original,
);
final TextEditingController translationController = TextEditingController(
text: gameCardDto.translation,
);
final TextEditingController transcriptionController = TextEditingController(
text: gameCardDto.transcription,
);
final TextEditingController transcriptionMnemoController =
TextEditingController(text: gameCardDto.transcriptionMnemo);
final TextEditingController mnemoController = TextEditingController(
text: gameCardDto.mnemo,
);
final TextEditingController backController = TextEditingController(
text: gameCardDto.back,
);
showDialog(
context: context,
builder: (c) {
return Center(
child: Scaffold(
body: Material(
child: ListView(
children: [
Header(
'${gameCardDto.id}',
hasPopButton: true,
trail: Row(
children: [
IconButton(
onPressed: _saveCard,
icon: Icon(Icons.save),
),
if (gameCardDto.id >= 0)
GestureDetector(
onLongPress: _deleteCard,
child: Icon(Icons.delete),
),
],
),
),
StatefulBuilder(
builder: (context, setState) {
return Stack(
alignment: Alignment.center,
children: [
Padding(
padding: EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
children: [
TextField(
controller: originalController,
onChanged: (v) {
gameCardDto = gameCardDto.copyWith(
original: v,
);
},
decoration: InputDecoration(
hintText: 'Original (cerdo)',
),
maxLines: 1,
style: theme.displayMedium?.copyWith(
fontSize:
theme.displayMedium!.fontSize!,
),
),
TextField(
controller: translationController,
onChanged: (v) {
gameCardDto = gameCardDto.copyWith(
translation: v,
);
},
decoration: InputDecoration(
hintText: 'Translation (свинья)',
),
maxLines: 1,
style: theme.headlineSmall?.copyWith(
color: theme.headlineSmall!.color!
.withOpacity(0.5),
fontSize:
theme.headlineSmall!.fontSize!,
),
),
Row(
children: [
Flexible(
child: TextField(
controller: transcriptionController,
onChanged: (v) {
gameCardDto = gameCardDto
.copyWith(transcription: v);
},
decoration: InputDecoration(
hintText: 'Transcription',
),
maxLines: 1,
style: theme.headlineSmall
?.copyWith(
color: theme
.headlineSmall!
.color!,
fontSize: theme
.headlineSmall!
.fontSize!,
),
),
),
SizedBox(width: 4.0),
Flexible(
child: TextField(
controller:
transcriptionMnemoController,
onChanged: (v) {
gameCardDto = gameCardDto
.copyWith(
transcriptionMnemo: v,
);
},
decoration: InputDecoration(
hintText: 'Transcription mnemo',
),
maxLines: 1,
style: theme.headlineSmall
?.copyWith(
color: theme
.headlineSmall!
.color!,
fontSize: theme
.headlineSmall!
.fontSize!,
),
),
),
],
),
],
),
SizedBox(
width: MediaQuery.of(context).size.width - 20,
height:
MediaQuery.of(context).size.width - 20,
child: GestureDetector(
onTap: () async {
FilePickerResult? result =
await FilePicker.platform.pickFiles();
if (result != null &&
result.files.isNotEmpty) {
imageChanged = true;
final file = result.files.first;
final encoded = base64.normalize(
base64.encode(
File(file.path!).readAsBytesSync(),
),
);
gameCardDto = gameCardDto.copyWith(
image: encoded,
);
}
setState(() {});
},
child: gameCardDto.image == null
? Container(
alignment: Alignment.center,
color: Colors.white,
child: Icon(
Icons.image,
color: Colors.grey,
),
)
: Stack(
children: [
Image(
key: ValueKey(base64),
image: MemoryImage(
base64.decode(
gameCardDto.image!,
),
),
gaplessPlayback: true,
fit: BoxFit.contain,
),
Positioned(
top: 8.0,
right: 8.0,
child: IconButton(
onPressed: () {
gameCardDto = gameCardDto
.copyWith(image: null);
setState(() {});
},
icon: Icon(Icons.delete),
),
),
],
),
),
),
TextField(
controller: mnemoController,
onChanged: (v) {
gameCardDto = gameCardDto.copyWith(
mnemo: v,
);
},
decoration: InputDecoration(
hintText: 'Mnemo ([свинья] с сердечком)',
),
maxLines: 1,
style: theme.headlineSmall?.copyWith(
fontSize: theme.headlineSmall!.fontSize!,
),
),
TextField(
controller: backController,
onChanged: (v) {
gameCardDto = gameCardDto.copyWith(back: v);
},
decoration: InputDecoration(
hintText: 'Задняя сторона',
),
maxLines: 1,
style: theme.headlineSmall?.copyWith(
fontSize: theme.headlineSmall!.fontSize!,
),
),
],
),
),
],
);
},
),
],
),
),
),
);
},
);
}
}

View file

@ -1,383 +0,0 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../di/injector.dart';
import '../main.dart';
import 'date_param.dart';
import 'enum_param.dart';
import 'int_param.dart';
import 'text_param.dart';
class AddDiscountCampaign {
late DiscountCampaignDto campaignDto;
late AdminApi adminApi;
List<DiscountCampaignDto> _campaigns = [];
AddDiscountCampaign() {
this.adminApi = getIt.get<AdminApi>();
_init(null);
}
void _init(DiscountCampaignDto? dto) {
final now = DateTime.now();
campaignDto = dto ??
DiscountCampaignDto(
start: now,
finish: now.add(Duration(days: 30)),
status: DiscountCampaignStatus.disabled,
name: null,
tags: [],
discounts: [],
);
}
Future<bool> _loadCampaigns() async {
try {
_campaigns = await adminApi.getDiscountCampaigns();
} catch (e, s) {
log('Error', error: e, stackTrace: s);
print('Error $e');
return false;
}
return true;
}
void _savePromoCodeCampaign() async {
for (final d in campaignDto.discounts) {
final products = d.products.map((p) => p.toProduct());
d.products.replaceRange(0, products.length, products);
}
final result = await adminApi.addDiscountCampaign(campaignDto);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void _deleteDiscountCampaign() async {
if (campaignDto.id == null) return;
final result =
await adminApi.deleteDiscountCampaign(campaignDto.id.toString());
if (result != null) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void addCampaign(BuildContext context) {
showDialog(
context: context,
builder: (c) {
return FutureBuilder(
future: _loadCampaigns(),
builder: (context, snapshot) {
if (snapshot.data != true) {
return Center(
child: Material(
child: Center(child: CircularProgressIndicator()),
),
);
}
return Center(
child: Scaffold(
body: Material(
child: StatefulBuilder(builder: (context, setCampaign) {
return ListView(
children: [
Header(
'${campaignDto.id}',
hasPopButton: true,
trail: Row(
children: [
IconButton(
onPressed: _savePromoCodeCampaign,
icon: Icon(Icons.save),
),
if (campaignDto.id != null)
IconButton(
onPressed: () async {
campaignDto =
campaignDto.copyWith(id: null);
_savePromoCodeCampaign();
},
icon: Icon(Icons.copy),
),
if (campaignDto.id != null)
GestureDetector(
onLongPress: _deleteDiscountCampaign,
child: Icon(Icons.delete),
)
],
),
),
DropdownMenu<int?>(
initialSelection: null,
onSelected: (int? value) {
setCampaign(() {
_init(
_campaigns
.firstWhereOrNull((v) => v.id == value),
);
});
},
dropdownMenuEntries: [
..._campaigns,
null,
].map((campaign) {
return DropdownMenuEntry(
value: campaign?.id,
label: campaign == null
? 'New'
: '${campaign.name ?? campaign.id} ${campaign.status.name}',
);
}).toList(),
),
StatefulBuilder(builder: (context, setState) {
return Padding(
padding: EdgeInsets.all(8.0),
child: Column(
children: [
TextParam(
'Name',
campaignDto.name,
(v) {
campaignDto = campaignDto.copyWith(
name: v,
);
},
),
DateParam(
'Start',
campaignDto.start,
(v) {
setState(() {
campaignDto =
campaignDto.copyWith(start: v);
});
},
),
DateParam(
'Finish',
campaignDto.finish,
(v) {
setState(() {
campaignDto =
campaignDto.copyWith(finish: v);
});
},
),
EnumParam(
'Status',
DiscountCampaignStatus.values,
campaignDto.status, (v) {
setState(() {
campaignDto =
campaignDto.copyWith(status: v);
});
}),
Text(
'Created to create or change\n'
'Disabled to disable\n'
'Dont use other statuses!',
style: TextStyle(fontSize: 12),
),
Text('Discounts:'),
ListView.builder(
shrinkWrap: true,
itemCount:
campaignDto.discounts.length + 1,
itemBuilder: (context, index) {
if (index ==
campaignDto.discounts.length) {
return Container(
child: SimpleTile(
title: Text('Add'),
onTap: () async {
final discount =
await _showDiscountDialog(
context, null);
if (discount != null) {
setState(() {
final ex = campaignDto
.discounts
.firstWhereOrNull((d) =>
d.id ==
discount.id);
if (ex != null) {
campaignDto.discounts
.remove(ex);
}
campaignDto.discounts
.add(discount);
});
}
},
),
);
}
final item =
campaignDto.discounts[index];
return ListTile(
title: Text(
'${item.type.name} ${item.id ?? ''}'),
onTap: () async {
final p = await _showDiscountDialog(
context,
item,
);
if (p != null) {
setState(() {
campaignDto.discounts.add(p);
});
}
},
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
setState(() {
campaignDto.discounts
.remove(item);
});
},
),
);
},
),
],
),
);
}),
],
);
}),
),
),
);
});
});
}
Future<DiscountDto?> _showDiscountDialog(
BuildContext context,
DiscountDto? initialDiscount,
) async {
var prod = initialDiscount ?? DiscountDto(discountPercent: 0, products: []);
final result = await showDialog<DiscountDto?>(
context: context,
builder: (c) => Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Material(
child: FutureBuilder(
future: () async {
final allPacks = await adminApi.packs();
final allPlans = await adminApi.getSubscriptionPlans();
return [...allPacks, ...allPlans];
}.call(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text('Loading products...'),
);
}
final products = snapshot.requireData;
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Adding discount'),
TextParam(
'Id',
prod.id,
(v) {
prod = prod.copyWith(id: v);
},
description: 'Leave empty for new',
),
IntParam('Discount 0-100', prod.discountPercent.toInt(),
(v) {
prod = prod.copyWith(
discountPercent: v?.toDouble() ?? 0.0);
}),
StatefulBuilder(builder: (context, ss) {
return ListView.builder(
shrinkWrap: true,
itemCount: prod.products.length + 1,
itemBuilder: (BuildContext context, int index) {
if (index == prod.products.length) {
return EnumParam(
'Add product',
products,
null,
(p) {
if (p != null) {
ss(() {
prod.products.add(p);
});
}
},
getName: (p) => _productName(p, products),
);
}
final p = prod.products[index];
return ListTile(
title: Text(_productName(p, products)),
onTap: () async {
ss(() {
prod.products.remove(p);
});
},
);
},
);
}),
MaterialButton(
onPressed: () {
Navigator.of(context).pop(prod);
},
child: Text('Save'),
),
],
),
);
}),
),
),
),
);
return result;
}
String _productName(MnemoCardsProductDto? p, List<MnemoCardsProductDto> products) {
if (p is CardPackPreviewDto) {
return '${p.id} ${p.title} ${p.price} pack';
} else if (p is SubscriptionPlanAdminDto) {
return '${p.id} ${p.ui.titleLead ?? ''} ${p.ui.title ?? ''} ${p.paymentSystem.name} ${p.price} plan';
}
final matchedProduct = products.firstWhereOrNull((m) => m.productEquals(p));
if (matchedProduct != null) {
return _productName(matchedProduct, []);
}
return '${p?.type.name} ${p?.id}';
}
}

View file

@ -1,298 +0,0 @@
import 'dart:convert';
import 'package:universal_io/io.dart';
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards/managers/repository/api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:file_picker/file_picker.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../utils/string_helper.dart';
import '../main.dart';
import 'text_param.dart';
class AddPackage with Api {
EditCardPackDto cardPackDto;
AdminApi adminApi;
bool palleteVisible = false;
AddPackage(EditCardPackDto? cardPackDto, this.adminApi)
: cardPackDto = cardPackDto ?? EditCardPackDto.fromJson({});
void _deletePack() async {
final delete = await showDialog(
context: scaffoldKey.currentContext!,
builder: (c) => Center(
child: Container(
color: Colors.white,
width: 200,
child: IconButton(
icon: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Удалить '),
Icon(Icons.delete),
],
),
onPressed: () {
Navigator.of(c).pop(true);
},
),
),
));
if (!delete) return;
final result = await adminApi.deletePack(cardPackDto);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void _savePack() async {
final result = await adminApi.editPack(cardPackDto);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void addPack(BuildContext context) {
showDialog(
context: context,
builder: (c) {
return Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListView(
children: [
Header(
'${cardPackDto.id}',
hasPopButton: true,
trail: Row(
children: [
IconButton(
onPressed: _savePack,
icon: Icon(Icons.save),
),
if (cardPackDto.id != null)
GestureDetector(
onLongPress: _deletePack,
child: Icon(Icons.delete),
)
],
),
),
TextParam(
'Title',
cardPackDto.title,
(v) {
cardPackDto = cardPackDto.copyWith.title(v);
},
),
TextParam(
'Subtitle',
cardPackDto.subtitle,
(v) {
cardPackDto = cardPackDto.copyWith.subtitle(v);
},
),
TextParam(
'Description',
cardPackDto.description,
(v) => cardPackDto = cardPackDto.copyWith.description(v),
),
TextParam(
'Size',
cardPackDto.size?.toString(),
(v) {
cardPackDto = cardPackDto.copyWith.size(
int.tryParse(v),
);
},
),
TextParam(
'Google play id',
cardPackDto.googlePlayId,
(v) {
cardPackDto = cardPackDto.copyWith.googlePlayId(v);
},
),
TextParam(
'Rustore id',
cardPackDto.rustoreId,
(v) {
cardPackDto = cardPackDto.copyWith.rustoreId(v);
},
),
TextParam(
'Appstore id',
cardPackDto.appStoreId,
(v) {
cardPackDto = cardPackDto.copyWith.appStoreId(v);
},
),
TextParam(
'Price',
cardPackDto.price,
(v) {
cardPackDto = cardPackDto.copyWith.price(v);
},
),
Row(
children: [
Expanded(child: Text('Enabled ')),
StatefulBuilder(builder: (context, setState) {
return Switch(
value: cardPackDto.enabled ?? false,
onChanged: (v) {
setState(() {
cardPackDto = cardPackDto.copyWith.enabled(v);
});
});
}),
],
),
StatefulBuilder(builder: (context, setState) {
return Column(
children: [
Row(
children: [
Expanded(child: Text('Поменять цвет ')),
Switch(
value: palleteVisible,
onChanged: (v) => setState(() {
palleteVisible = !palleteVisible;
}),
),
],
),
if (palleteVisible)
ColorPicker(
pickerAreaHeightPercent: 0.5,
paletteType: PaletteType.hsvWithHue,
pickerColor: cardPackDto.color?.asColor ??
Colors.transparent,
onColorChanged: (color) {
String? colorString;
if (color.alpha != 0) {
colorString = '#${color.toHexString()}';
}
cardPackDto =
cardPackDto.copyWith.color(colorString);
},
),
],
);
}),
StatefulBuilder(builder: (context, setState) {
return SizedBox(
width: MediaQuery.of(context).size.width - 20,
height: MediaQuery.of(context).size.width - 20,
child: GestureDetector(
onTap: () async {
FilePickerResult? result =
await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
final encoded = base64.normalize(
base64.encode(
File(file.path!).readAsBytesSync(),
),
);
cardPackDto = cardPackDto.copyWith.cover(
encoded,
);
}
setState(() {});
},
child: cardPackDto.cover == null
? Container(
alignment: Alignment.center,
color: Colors.white,
child: Icon(
Icons.image,
color: Colors.grey,
),
)
: Stack(
children: [
ImageFade(
key: ValueKey(cardPackDto.id),
image: MemoryImage(
base64.decode(cardPackDto.cover!)),
fit: BoxFit.contain,
syncDuration: Duration.zero,
),
Positioned(
top: 8.0,
right: 8.0,
child: IconButton(
onPressed: () {
cardPackDto = cardPackDto.copyWith(
cover: null,
);
setState(() {});
},
icon: Icon(Icons.delete)),
),
],
),
),
);
}),
TextParam(
'Preview cards',
cardPackDto.previewCards?.toString(),
(v) {
final cards = v
.decodeList<int>()
.map((e) => e.toString())
.toList();
cardPackDto = cardPackDto.copyWith.previewCards(cards);
},
),
TextParam(
'Order (lower is higher)',
cardPackDto.order?.toString(),
(v) {
cardPackDto = cardPackDto.copyWith.order(
int.tryParse(v),
);
},
),
TextParam(
'Cards order (ids)',
cardPackDto.cardsOrder?.join(','),
(v) {
final ids = v
.split(',')
.map((s) => int.tryParse(s.trim()))
.whereNotNull()
.toList();
cardPackDto = cardPackDto.copyWith(cardsOrder: ids);
},
),
],
),
),
),
);
});
}
}

View file

@ -1,389 +0,0 @@
import 'package:flutter/material.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../di/injector.dart';
import '../main.dart';
import 'text_param.dart';
class AddPlan {
List<SubscriptionPlanAdminDto> allPlans = [];
List<SubscriptionFeatureEnum> selectedFeatures = [];
late SubscriptionPlanAdminDto planDto;
late AdminApi adminApi;
final availableFeatures = [
SubscriptionFeatureEnum.ads,
SubscriptionFeatureEnum.packs,
];
AddPlan() {
this.adminApi = getIt.get<AdminApi>();
_init(null);
}
void _init(SubscriptionPlanAdminDto? dto) {
planDto = dto ??
const SubscriptionPlanAdminDto(
ui: SubscriptionPlanUIDto(
currency: 'руб',
),
price: '123',
currency: 'руб',
durationDays: 0,
features: [
SubscriptionFeatureEnum.ads,
SubscriptionFeatureEnum.packs,
],
paymentId: null,
paymentSystem: PaymentSystem.unknown,
);
selectedFeatures = planDto.features.toList();
}
Future<bool> _loadPlans() async {
final result = await adminApi.getSubscriptionPlans();
allPlans = result;
return true;
}
void _saveSubscriptionPlan() async {
final result = await adminApi.addSubscriptionPlan(
planDto.copyWith(
features: selectedFeatures,
),
);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void _deletePLan() async {
if (planDto.id == null) return;
final result = await adminApi.deleteSubscriptionPlan(planDto.id.toString());
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void addPlan(BuildContext context) {
showDialog(
context: context,
builder: (c) {
return FutureBuilder(
future: _loadPlans(),
builder: (context, snapshot) {
if (snapshot.data != true) {
return Center(
child: Material(
child: Center(child: CircularProgressIndicator()),
),
);
}
return Center(
child: Scaffold(
body: Material(
child: StatefulBuilder(builder: (context, setPlan) {
return ListView(
children: [
Header(
'${planDto.id}',
hasPopButton: true,
trail: Row(
children: [
IconButton(
onPressed: () async {
await _loadPlans();
setPlan(() {});
},
icon: Icon(Icons.sync),
),
IconButton(
onPressed: _saveSubscriptionPlan,
icon: Icon(Icons.save),
),
if (planDto.id != null)
IconButton(
onPressed: () async {
planDto = planDto.copyWith(id: null);
_saveSubscriptionPlan();
},
icon: Icon(Icons.copy),
),
if (planDto.id != null)
GestureDetector(
onLongPress: _deletePLan,
child: Icon(Icons.delete),
)
],
),
),
DropdownMenu<String?>(
initialSelection: null,
onSelected: (String? value) {
setPlan(() {
_init(
allPlans
.firstWhereOrNull((v) => v.id == value),
);
});
},
dropdownMenuEntries: [
...allPlans,
null,
].map((plan) {
return DropdownMenuEntry(
value: plan?.id,
label: plan == null
? 'New'
: '${plan.ui.titleLead ?? ''} ${plan.ui.title ?? ''} ${plan.paymentSystem.name}',
);
}).toList(),
),
StatefulBuilder(builder: (context, setState) {
return Stack(
alignment: Alignment.center,
children: [
Padding(
padding: EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
Column(
children: [
TextParam(
'Currency',
planDto.currency,
(v) {
planDto = planDto.copyWith(
currency: v);
},
),
TextParam(
'Price',
planDto.price,
(v) {
planDto =
planDto.copyWith(price: v);
},
),
TextParam(
'Days',
planDto.durationDays.toString(),
(v) {
final days = int.parse(v);
planDto = planDto.copyWith(
durationDays: days,
);
},
),
Text('Features:'),
ListView.builder(
shrinkWrap: true,
itemCount:
availableFeatures.length,
itemBuilder: (context, index) {
final item =
availableFeatures[index];
return ListTile(
title: Text(item.name),
onTap: () {
setState(() {
if (selectedFeatures
.contains(item)) {
selectedFeatures
.remove(item);
} else {
selectedFeatures
.add(item);
}
});
},
selected: selectedFeatures
.contains(item),
selectedTileColor: Colors.blue
.withOpacity(0.5),
);
},
),
Text('UI:'),
TextParam(
'UI Currency',
planDto.ui.currency,
(v) {
final ui = planDto.ui.copyWith(
currency: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
TextParam(
'UI Title lead',
planDto.ui.titleLead,
(v) {
final ui = planDto.ui.copyWith(
titleLead: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
TextParam(
'UI Title',
planDto.ui.title,
(v) {
final ui = planDto.ui.copyWith(
title: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
TextParam(
'UI Price per month',
planDto.ui.pricePerMonth,
(v) {
final ui = planDto.ui.copyWith(
pricePerMonth: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
TextParam(
'UI Subtitle crossed',
planDto.ui.subtitleCrossed,
(v) {
final ui = planDto.ui.copyWith(
subtitleCrossed: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
TextParam(
'UI Subtitle',
planDto.ui.subtitle,
(v) {
final ui = planDto.ui.copyWith(
subtitle: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
TextParam(
'UI Tip',
planDto.ui.tip,
(v) {
final ui = planDto.ui.copyWith(
tip: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
TextParam(
'UI Tip color',
planDto.ui.tipColor,
(v) {
final ui = planDto.ui.copyWith(
tipColor: v,
);
planDto =
planDto.copyWith.ui(ui);
},
),
Padding(
padding:
const EdgeInsets.all(8.0),
child: Row(
children: [
Text('Payment type '),
Expanded(
child: StatefulBuilder(
builder: (context,
setState) {
return DropdownButton<
PaymentSystem>(
value: planDto
.paymentSystem,
items:
PaymentSystem.values
.map(
(v) =>
DropdownMenuItem(
child: Text(
v.name),
value: v,
),
)
.toList(),
onChanged: (v) {
setState(() {
planDto = planDto
.copyWith(
paymentSystem: v ??
PaymentSystem
.unknown,
);
});
},
);
}),
),
],
),
),
TextParam(
'Payment id',
planDto.paymentId,
(v) {
planDto = planDto.copyWith(
paymentId: v,
);
},
),
],
),
],
),
),
],
);
}),
],
);
}),
),
),
);
});
});
}
Widget uiEditor() {
return Column(
children: [],
);
}
}

View file

@ -1,399 +0,0 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../di/injector.dart';
import '../main.dart';
import 'date_param.dart';
import 'enum_param.dart';
import 'int_param.dart';
import 'text_param.dart';
class AddPromocodeCampaign {
late PromoCodesCampaignDto campaignDto;
late AdminApi adminApi;
List<PromoCodesCampaignDto> _campaigns = [];
final availableStatuses = [
PromoCodeCampaignStatus.created,
PromoCodeCampaignStatus.preparing,
PromoCodeCampaignStatus.ready,
PromoCodeCampaignStatus.active,
PromoCodeCampaignStatus.disabled,
];
AddPromocodeCampaign() {
this.adminApi = getIt.get<AdminApi>();
_init(null);
}
void _init(PromoCodesCampaignDto? dto) {
final now = DateTime.now();
campaignDto = dto ??
PromoCodesCampaignDto(
products: [],
activationsPerCode: 1,
activationsPerUser: 1,
generationSize: 100,
start: now,
finish: now.add(Duration(days: 60)),
status: PromoCodeCampaignStatus.disabled,
name: null,
template: r'$s$s$s$s$s$s$s$s',
tags: [],
);
}
Future<bool> _loadCampaigns() async {
try {
_campaigns = await adminApi.getPromoCodeCampaigns();
} catch (e, s) {
log('Error', error: e, stackTrace: s);
print('Error $e');
return false;
}
return true;
}
Future<bool> _loadCampaign(String id) async {
try {
final campaign = await adminApi.getPromoCodeCampaign(id);
if (campaign != null) {
_campaigns.removeWhere((v) => v.id.toString() == id);
_campaigns.add(campaign);
campaignDto = campaign;
}
} catch (e, s) {
log('Error', error: e, stackTrace: s);
print('Error $e');
return false;
}
return true;
}
void _savePromoCodeCampaign() async {
final result = await adminApi.addPromoCodeCampaign(campaignDto);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void _deletePromoCodeCampaign() async {
if (campaignDto.id == null) return;
final result =
await adminApi.deletePromoCodeCampaign(campaignDto.id.toString());
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void addCampaign(BuildContext context) {
showDialog(
context: context,
builder: (c) {
return FutureBuilder(
future: _loadCampaigns(),
builder: (context, snapshot) {
if (snapshot.data != true) {
return Center(
child: Material(
child: Center(child: CircularProgressIndicator()),
),
);
}
return Center(
child: Scaffold(
body: Material(
child: StatefulBuilder(builder: (context, setCampaign) {
final codes = campaignDto.promoCodes;
return ListView(
children: [
Header(
'${campaignDto.id}',
hasPopButton: true,
trail: Row(
children: [
if (campaignDto.id != null)
IconButton(
onPressed: () async {
await _loadCampaign(
campaignDto.id.toString(),
);
setCampaign(() {});
},
icon: Icon(Icons.note),
),
IconButton(
onPressed: _savePromoCodeCampaign,
icon: Icon(Icons.save),
),
if (campaignDto.id != null)
IconButton(
onPressed: () async {
campaignDto =
campaignDto.copyWith(id: null);
_savePromoCodeCampaign();
},
icon: Icon(Icons.copy),
),
if (campaignDto.id != null)
GestureDetector(
onLongPress: _deletePromoCodeCampaign,
child: Icon(Icons.delete),
)
],
),
),
DropdownMenu<int?>(
initialSelection: null,
onSelected: (int? value) {
setCampaign(() {
_init(
_campaigns
.firstWhereOrNull((v) => v.id == value),
);
});
},
dropdownMenuEntries: [
..._campaigns,
null,
].map((campaign) {
return DropdownMenuEntry(
value: campaign?.id,
label: campaign == null
? 'New'
: '${campaign.name ?? campaign.id} ${campaign.status.name}',
);
}).toList(),
),
StatefulBuilder(builder: (context, setState) {
return Padding(
padding: EdgeInsets.all(8.0),
child: Column(
children: [
TextParam(
'Name',
campaignDto.name,
(v) {
campaignDto = campaignDto.copyWith(
name: v,
);
},
),
TextParam(
'Template',
campaignDto.template,
(v) {
campaignDto = campaignDto.copyWith(
template: v,
);
},
description:
r'$d - digit $l - letter $s - any',
),
IntParam(
'Generation size',
campaignDto.generationSize,
(v) {
campaignDto = campaignDto.copyWith(
generationSize: v);
},
),
IntParam(
'Activations per code',
campaignDto.activationsPerCode,
(v) {
campaignDto = campaignDto.copyWith(
activationsPerCode: v,
);
},
),
IntParam(
'Activations per user',
campaignDto.activationsPerUser,
(v) {
campaignDto = campaignDto.copyWith(
activationsPerUser: v,
);
},
),
DateParam(
'Start',
campaignDto.start,
(v) {
setState(() {
campaignDto =
campaignDto.copyWith(start: v);
});
},
),
DateParam(
'Finish',
campaignDto.finish,
(v) {
setState(() {
campaignDto =
campaignDto.copyWith(finish: v);
});
},
),
EnumParam(
'Status',
PromoCodeCampaignStatus.values,
campaignDto.status, (v) {
setState(() {
campaignDto =
campaignDto.copyWith(status: v);
});
}),
Text(
'Created to generate promocodes\n'
'Disabled to disable\n'
'Dont use other statuses!',
style: TextStyle(fontSize: 12),
),
Text('Products:'),
ListView.builder(
shrinkWrap: true,
itemCount:
campaignDto.products.length + 1,
itemBuilder: (context, index) {
if (index ==
campaignDto.products.length) {
return ListTile(
title: Text('Add'),
onTap: () async {
final p =
await _showProductDialog(
context, null);
if (p != null) {
setState(() {
campaignDto.products.add(p);
});
}
},
);
}
final item =
campaignDto.products[index];
return ListTile(
title: Text(
'${item.type.name} ${item.id ?? ''}'),
onTap: () async {
final p = await _showProductDialog(
context,
item,
);
if (p != null) {
setState(() {
campaignDto.products.add(p);
});
}
},
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
setState(() {
campaignDto.products
.remove(item);
});
},
),
);
},
),
SimpleTile.text(
text: codes == null
? 'Codes not loaded'
: 'Codes: ${codes.length == 1 ? codes.first : codes.length}',
onTap: () async {
if (codes != null) {
await Clipboard.setData(
ClipboardData(
text: codes.join('\n'),
),
);
}
},
),
],
),
);
}),
],
);
}),
),
),
);
});
});
}
Future<MnemoCardsProductDto?> _showProductDialog(
BuildContext context,
MnemoCardsProductDto? initialProduct,
) async {
var prod = initialProduct ?? MnemoCardsProductDto();
final result = await showDialog<MnemoCardsProductDto?>(
context: context,
builder: (c) => Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Material(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextParam('Id', prod.id, (v) {
prod = prod.copyWith(id: v);
}),
EnumParam(
'Type',
MnemoCardsProductType.values,
prod.type,
(v) {
prod = prod.copyWith(type: v);
},
),
MaterialButton(
onPressed: () {
Navigator.of(context).pop(prod);
},
child: Text('Save'),
),
],
),
),
),
),
);
return result;
}
Widget uiEditor() {
return Column(
children: [],
);
}
}

View file

@ -1,228 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../main.dart';
class EditUser {
UserDto userDto;
AdminApi adminApi;
bool imageChanged = false;
EditUser(UserDto userDto, this.adminApi) : userDto = userDto;
void _saveUser() async {
final dto = userDto;
final result = await adminApi.editUser(dto);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void _deleteCard() async {
final result = await adminApi.deleteUser(userDto);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
appRouter.maybePop();
}
}
void addUser(BuildContext context) {
final theme = Theme.of(context).textTheme;
final TextEditingController nameController =
TextEditingController(text: userDto.name);
final TextEditingController packsController =
TextEditingController(text: userDto.packs.join(','));
showDialog(
context: context,
builder: (c) {
return Center(
child: Scaffold(
body: Material(
child: ListView(
children: [
Header(
'${userDto.id}',
hasPopButton: true,
trail: Row(
children: [
IconButton(
onPressed: _saveUser,
icon: Icon(Icons.save),
),
if (userDto.id != null && userDto.id! >= 0)
GestureDetector(
onLongPress: _deleteCard,
child: Icon(Icons.delete),
)
],
),
),
StatefulBuilder(builder: (context, setState) {
return Padding(
padding: EdgeInsets.all(8.0),
child: Column(
children: [
GestureDetector(
onTap: () {
if (userDto.id != null) {
Clipboard.setData(ClipboardData(
text: userDto.id.toString()));
}
},
child: Text('ID: ${userDto.id}'),
),
Text('Admin: ${userDto.admin}'),
TextField(
controller: nameController,
onChanged: (v) {
userDto = userDto.copyWith(name: v);
},
decoration: InputDecoration(hintText: 'Name'),
maxLines: 1,
style: theme.displayMedium?.copyWith(
fontSize: theme.displayMedium!.fontSize!,
),
),
Row(
children: [
Expanded(child: Text('Subscription ')),
StatefulBuilder(builder: (context, setState) {
return Switch(
value: userDto.subscription ?? false,
onChanged: (v) {
setState(() {
userDto =
userDto.copyWith.subscription(v);
});
});
}),
],
),
TextField(
controller: packsController,
onChanged: (v) {
final packs = v
.split(',')
.map((s) =>
int.tryParse(s.trim())?.toString())
.whereNotNull()
.toList();
userDto = userDto.copyWith(packs: packs);
final packsText = packs.join(',');
if (!packsController.text
.startsWith(packsText)) {
packsController.text = packsText;
}
},
decoration: InputDecoration(hintText: 'Packs'),
maxLines: 1,
style: theme.displayMedium?.copyWith(
fontSize: theme.displayMedium!.fontSize!,
),
),
],
),
);
}),
_Payments(adminApi, userDto),
Text(
'Stats: ${userDto.userDataDto?.allWordsStatistics?.total} words',
),
Text(
'Stats: ${userDto.userDataDto?.allTestsStatistics?.tests.length} tests',
),
Text(
'Last test session: ${userDto.userDataDto?.allTestsStatistics?.lastSessionToken}',
),
],
),
),
),
);
});
}
}
class _Payments extends StatelessWidget {
final AdminApi adminApi;
final UserDto userDto;
_Payments(this.adminApi, this.userDto);
@override
Widget build(BuildContext context) {
final sc = StreamController<List<PaymentDto>?>();
final stream = sc.stream;
return ExpansionTile(
title: Text('Транзакции'),
maintainState: true,
onExpansionChanged: (v) async {
if (v) {
sc.add(null);
final payments =
await adminApi.getUserPurchases(userDto.id.toString());
sc.add(payments);
}
},
children: [
StreamBuilder<List<PaymentDto>?>(
stream: stream,
builder: (context, snapshot) {
final payments = snapshot.data;
if (payments == null) {
return Text('Loading...');
}
if (payments.isEmpty) {
return Text('Нет покупок');
}
return Column(
children: payments.map((p) {
return ListTile(
title: Text('${p.amount} ${p.currency}'),
subtitle: Text(dateString(p.date)),
trailing: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(p.status.name),
Text(p.paymentSystem.name),
Text(
p.products.map((p) => '${p.type}: ${p.id}').join('\n'),
),
],
),
);
}).toList(),
);
},
),
],
);
}
String dateString(DateTime date) =>
'${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}';
}

View file

@ -1,196 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:mnemo_cards/di/injector.dart';
import 'package:mnemo_cards/features/packs/packs_api.dart';
import 'package:mnemo_cards/features/worker/worker_ext.dart';
import 'package:mnemo_cards/managers/repository/api.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
class AdminApi with Api {
final Dio _dio;
AdminApi(this._dio);
Future<bool> addCard(
GameCardDto dto, {
ProgressCallback? progressCallback,
}) async {
final r = await _dio.post<String>(
'$path/cards/add',
data: jsonEncode(dto.toJson()),
options: Options(sendTimeout: Duration(seconds: 30)),
onSendProgress: (a, b) {
log('Card send progress $a/$b');
progressCallback?.call(a, b);
},
onReceiveProgress: (a, b) {
log('Card receive progress $a/$b');
progressCallback?.call(a, b);
},
);
return r.statusCode == 200;
}
Future<bool> deleteCard(GameCardDto dto) async {
final r = await _dio.post<String>(
'$path/cards/delete',
data: jsonEncode({'id': dto.id.toString()}),
);
return r.statusCode == 200;
}
Future<List<String>> getCardIds() async {
final r = await _dio.get<String>('$path/cards/ids');
return (jsonDecode(r.data!)['ids'] as String).split(',').cast();
}
Future<List<GameCardDto>> getCards(List<String> ids) async {
final r = await _dio.get<String>(
'$path/cards',
queryParameters: {'ids': ids.join(',')},
);
return (r.data as String).decodeList(GameCardDto.fromJson);
}
Future<List<UserDto>> getUsers(List<String> ids) async {
final r = await _dio.get<String>(
'$path/users',
queryParameters: {'ids': ids.join(',')},
);
return (jsonDecode(r.data!) as List)
.map((e) => UserDto.fromJson(e as Map<String, dynamic>))
.toList()
.cast();
}
Future<List<String>> getUserIds() async {
final r = await _dio.get<String>('$path/users/ids');
return (jsonDecode(r.data!)['ids'] as String).split(',').cast();
}
Future<List<PaymentDto>> getUserPurchases(String userId) async {
final r = await _dio.get<String>(
'$path/user/purchases',
queryParameters: {'id': userId},
);
return (jsonDecode(r.data!)['payments'] as List)
.cast<Map<String, Object?>>()
.map(PaymentDto.fromJson)
.toList();
}
Future<bool> deleteUser(UserDto dto) async {
final r = await _dio.post<String>(
'$path/users/delete',
data: jsonEncode({'id': dto.id.toString()}),
);
return r.statusCode == 200;
}
Future<bool> editUser(UserDto dto) async {
final r = await _dio.post<String>(
'$path/users/add',
data: jsonEncode(dto.toJson()),
);
return r.statusCode == 200;
}
Future<EditCardPackDto?> getEditPack(String id) async {
final r = await _dio.get<String>('$path/pack/edit/$id');
return (r.data as String).decode(EditCardPackDto.fromJson);
}
Future<bool> deletePack(EditCardPackDto dto) async {
final r = await _dio.post<String>(
'$path/pack/delete',
data: jsonEncode({'id': dto.id.toString()}),
);
return r.statusCode == 200;
}
Future<bool> editPack(EditCardPackDto dto) async {
final r = await _dio.post<String>(
'$path/pack/edit',
data: jsonEncode(dto.toJson()),
);
return r.statusCode == 200;
}
Future<List<CardPackPreviewDto>> packs() =>
getIt.get<PacksApi>().getPacksPreviews(null);
Future<List<SubscriptionPlanAdminDto>> getSubscriptionPlans() async {
final r = await _dio.get<String>('$path/subscriptions');
return (jsonDecode(r.data!)['plans'] as List)
.cast<Map<String, Object?>>()
.map(SubscriptionPlanAdminDto.fromJson)
.toList();
}
Future<bool> addSubscriptionPlan(SubscriptionPlanAdminDto dto) async {
final r = await _dio.post<String>(
'$path/subscription/add',
data: jsonEncode(dto.toJson()),
);
return r.statusCode == 200;
}
Future<bool> deleteSubscriptionPlan(String id) async {
final r = await _dio.post<String>('$path/subscription/delete/$id');
return r.statusCode == 200;
}
Future<bool> deletePromoCodeCampaign(String id) async {
final r = await _dio.post<String>('$path/promocode/delete/$id');
return r.statusCode == 200;
}
Future<bool> addPromoCodeCampaign(PromoCodesCampaignDto dto) async {
final r = await _dio.post<String>(
'$path/promocode/add',
data: dto.encode(),
);
return r.statusCode == 200;
}
Future<List<PromoCodesCampaignDto>> getPromoCodeCampaigns() async {
final r = await _dio.get<String>('$path/promocode/list');
return (jsonDecode(r.data!)['campaigns'] as List)
.cast<Map<String, Object?>>()
.map(PromoCodesCampaignDto.fromJson)
.toList();
}
Future<PromoCodesCampaignDto?> getPromoCodeCampaign(String id) async {
final r = await _dio.get<String>('$path/promocode/$id');
return r.data?.isolatedDecode(PromoCodesCampaignDto.fromJson);
}
Future<bool> addDiscountCampaign(DiscountCampaignDto dto) async {
final r = await _dio.post<String>(
'$path/discounts/add',
data: dto.encode(),
);
return r.statusCode == 200;
}
Future<List<DiscountCampaignDto>> getDiscountCampaigns() async {
final r = await _dio.get<String>('$path/discounts/list');
return (jsonDecode(r.data!)['campaigns'] as List)
.cast<Map<String, Object?>>()
.map(DiscountCampaignDto.fromJson)
.toList();
}
Future<String?> deleteDiscountCampaign(String id) async {
try {
final r = await _dio.post<String>('$path/discounts/delete/$id');
return (jsonDecode(r.data!)['error'] as String?);
} catch (e, s) {
return e.toString();
}
}
}

View file

@ -1,367 +0,0 @@
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mnemo_cards/admin/add_card.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards/di/injector.dart';
import 'package:mnemo_cards/managers/repository/dio_provider.dart';
import 'package:mnemo_cards/theme/themes.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../main.dart';
import 'add_package.dart';
class AllCards extends StatefulWidget {
@override
State<StatefulWidget> createState() => _AllCardsState();
const AllCards();
}
class _AllCardsState extends State<AllCards> {
static List<String> ids = [];
static Set<GameCardDto> cards = {};
static String filter = '';
static List<CardPackPreviewDto> _packPreviews = [];
static EditCardPackDto? _currentPack;
static String? _currentId;
static List<String> _selectedCards = [];
static Map<String, String> _cache = {};
bool get selectingMode => _selectedCards.isNotEmpty;
Dio get dio => getIt.get<DioProvider>().dio;
AdminApi get api => getIt.get<AdminApi>();
Future<void> loadCards() async {
var loadingIds = ids.toList();
cards.clear();
_cache.clear();
while (loadingIds.isNotEmpty) {
try {
final ids = loadingIds.take(10).toList();
loadingIds.removeRange(0, ids.length);
final cardsPage = await api.getCards(ids);
for (final card in cardsPage) {
final imageString =
card.image?.substring(0, min(100, card.image!.length)) ?? '';
_cache[card.id.toString()] =
'${card.id}${card.mnemo}${card.transcription}${card.transcriptionMnemo}${card.original}${card.translation}${imageString}';
}
cards.addAll(cardsPage);
setState(() {});
} catch (e) {
print(e);
}
}
}
Future<void> loadIds() async {
ids = await api.getCardIds();
setState(() {});
}
Future<void> loadPacks() async {
_packPreviews = await api.packs();
clearCurrentPack();
}
Future<void> loadCurrentPack() async {
if (_currentId != null) {
_currentPack = await api.getEditPack(_currentId!);
setState(() {});
}
}
void clearCurrentPack() {
_currentId = null;
_currentPack = null;
setState(() {});
}
Future<void> editPack(EditCardPackDto pack) async {
final result = await api.editPack(pack);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
loadCurrentPack();
_selectedCards.clear();
}
}
void addOrEditPack() => AddPackage(_currentPack, api).addPack(context);
List<GameCardDto> buildCards() {
final filteredCards = cards.where((dto) {
final filterOk =
filter.isEmpty || _cache[dto.id.toString()]?.contains(filter) == true;
final packFilter =
_currentPack?.addCardIds?.contains(dto.id.toString()) ?? true;
return filterOk && packFilter;
});
return filteredCards.toList();
}
@override
Widget build(BuildContext context) {
final filteredCards = buildCards();
return Scaffold(
body: SafeArea(
child: Stack(
children: [
ListView.builder(
itemExtentBuilder: (index, dims) => index >= 1 ? 160.h : 350,
itemBuilder: (context, index) {
if (index == 0)
return Column(
children: [
Header(
_currentId == null
? 'Все карточки'
: _currentPack?.title ??
'Пак ${_currentPack?.id}',
subtitle: filteredCards.isEmpty
? 'Нет карточек'
: '${filteredCards.length} карточек',
hasPopButton: true,
trail: IconButton(
onPressed: () async {
setState(() {});
await loadIds();
await loadPacks();
await loadCards();
},
icon: Icon(Icons.sync)),
),
dropdownButton(context),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: InputDecoration(hintText: 'Поиск'),
onChanged: (v) {
if (filter != v.toLowerCase()) {
filter = v.toLowerCase();
setState(() {});
}
},
),
),
Expanded(
child: GestureDetector(
onTap: () => EditCard(null, api).addCard(context),
child: Container(
color: Colors.green.withOpacity(0.1),
alignment: Alignment.center,
child: Icon(Icons.add, size: 30),
),
),
),
],
);
if (filteredCards.length >= (index - 1) * 3) {
final cardIndex = (index - 1) * 3;
return Row(
children: [
if (filteredCards.length > cardIndex)
cardWidget(filteredCards[cardIndex]),
if (filteredCards.length > cardIndex + 1)
cardWidget(filteredCards[cardIndex + 1]),
if (filteredCards.length > cardIndex + 2)
cardWidget(filteredCards[cardIndex + 2]),
],
);
}
return null;
}),
if (selectingMode)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 100,
padding: EdgeInsets.all(10.0),
alignment: Alignment.center,
child: Row(
children: [
GestureDetector(
onTap: () {
_selectedCards.clear();
setState(() {});
},
child: Container(
color: Colors.blue,
padding: EdgeInsets.all(8.0),
child: Row(
children: [
Text('${_selectedCards.length} '),
Icon(Icons.clear)
],
),
),
),
if (_currentPack != null)
if (_selectedCards.every((c) =>
_currentPack!.addCardIds?.contains(c) ?? false))
Row(
children: [
SizedBox(width: 8.0),
GestureDetector(
onTap: () {
final updated =
_currentPack!.copyWith.removeCardIds(
_selectedCards.toList(),
);
editPack(updated);
},
child: Container(
padding: EdgeInsets.all(8.0),
color: Colors.blue,
child: Text('Убрать из пака'),
),
),
SizedBox(width: 8.0),
GestureDetector(
onTap: () {
final updated =
_currentPack!.copyWith.previewCards(
_selectedCards.toList(),
);
editPack(updated);
},
child: Container(
padding: EdgeInsets.all(8.0),
color: Colors.blue,
child: Text('Как превью'),
),
),
],
)
else
MaterialButton(
onPressed: () {
final updated = _currentPack!.copyWith.addCardIds(
_selectedCards.toList(),
);
editPack(updated);
},
child: Container(
padding: EdgeInsets.all(8.0),
color: Colors.blue,
child: Text('Добавить в пак'),
),
)
],
),
),
),
],
),
),
);
}
Widget cardWidget(GameCardDto card) {
void toggleSelect() {
final id = card.id.toString();
if (_selectedCards.contains(id)) {
_selectedCards.remove(id);
} else {
_selectedCards.add(card.id.toString());
}
setState(() {});
}
return GestureDetector(
onTap: () {
if (selectingMode) {
toggleSelect();
} else {
EditCard(card, api).addCard(context);
}
},
onLongPress: toggleSelect,
child: _Card(card, api, _selectedCards.contains(card.id.toString())),
);
}
void _onPackChanged(String? id) {
if (id == null) {
clearCurrentPack();
} else {
_currentId = id;
loadCurrentPack();
}
}
Widget dropdownButton(BuildContext context) {
return Row(
children: [
Expanded(
child: DropdownButton(
items: [..._packPreviews, null]
.map((p) => DropdownMenuItem<String?>(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: p == null
? Text('Все паки')
: Text('${p.title} ${p.cards}'),
),
value: p?.id,
))
.toList(),
value: _currentId,
onChanged: (v) => _onPackChanged(v),
),
),
IconButton(
onPressed: addOrEditPack,
icon: _currentPack == null ? Icon(Icons.add) : Icon(Icons.edit),
),
],
);
}
}
class _Card extends StatelessWidget {
final GameCardDto cardDto;
final AdminApi api;
final bool selected;
_Card(this.cardDto, this.api, this.selected);
@override
Widget build(BuildContext context) {
final image = cardDto.image != null ? base64.decode(cardDto.image!) : null;
return Container(
padding: EdgeInsets.all(8.0),
width: MediaQuery.of(context).size.width / 3,
height: 160.h,
decoration: BoxDecoration(
border: Border.all(color: selected ? Colors.blue : borderGray),
color: selected ? Colors.blue.withOpacity(0.2) : null,
),
child: Column(children: [
Text(cardDto.translation ?? ''),
Text(cardDto.original ?? ''),
if (image != null)
Expanded(
child: Image.memory(
key: ObjectKey(cardDto.image),
gaplessPlayback: true,
image,
fit: BoxFit.scaleDown,
),
),
]),
);
}
}

View file

@ -1,230 +0,0 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:mnemo_cards/admin/add_user.dart';
import 'package:mnemo_cards/admin/admin_api.dart';
import 'package:mnemo_cards/di/injector.dart';
import 'package:mnemo_cards/di/locator.dart';
import 'package:mnemo_cards/managers/repository/dio_provider.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_frontend_common/mnemo_cards_frontend_common.dart';
import '../main.dart';
class AllUsers extends StatefulWidget {
@override
State<StatefulWidget> createState() => _AllUsersState();
const AllUsers();
}
class _AllUsersState extends State<AllUsers> {
static List<String> ids = [];
static Set<UserDto> users = {};
static String filter = '';
static UserDto? _currentUser;
static String? _currentId;
bool get selectingMode => false; //_selectedCards.isNotEmpty;
Dio get dio => getIt.get<DioProvider>().dio;
AdminApi get api => getIt.get<AdminApi>();
Future<void> loadUsers() async {
var loadingIds = ids.toList();
users.clear();
while (loadingIds.isNotEmpty) {
try {
final ids = loadingIds.take(10).toList();
loadingIds.removeRange(0, ids.length);
final usersPage = await api.getUsers(ids);
users.addAll(usersPage);
setState(() {});
} catch (e) {
print(e);
}
}
}
Future<void> loadIds() async {
ids = await api.getUserIds();
setState(() {});
}
Future<void> loadCurrentUser() async {
if (_currentId != null) {
_currentUser = (await api.getUsers([_currentId!])).firstOrNull;
setState(() {});
}
}
void clearCurrentUser() {
_currentId = null;
_currentUser = null;
setState(() {});
}
Future<void> editUser(UserDto user) async {
final result = await api.editUser(user);
if (!result) {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('ERROR'),
));
} else {
ScaffoldMessenger.of(scaffoldKey.currentContext!).showSnackBar(SnackBar(
content: Text('Success!!!'),
));
}
}
List<UserDto> buildUsersList() {
final filteredUsers = users.where((dto) {
final filterOk = filter.isEmpty ||
dto.name.toString().toLowerCase().contains(filter) ||
dto.id.toString().toLowerCase().contains(filter) ||
dto.packs.any((p) => p.toLowerCase().contains(filter));
return filterOk;
});
return filteredUsers.toList();
}
@override
Widget build(BuildContext context) {
final filteredUsers = buildUsersList();
return Scaffold(
body: SafeArea(
child: Stack(
children: [
ListView.builder(itemBuilder: (context, index) {
if (index == 0)
return Container(
height: 270,
child: Column(
children: [
Header(
_currentId == null
? 'Все'
: _currentUser?.name ?? 'Пак ${_currentUser?.id}',
subtitle: filteredUsers.isEmpty
? 'Нет пользователей'
: '${filteredUsers.length} пользователя',
hasPopButton: true,
trail: IconButton(
onPressed: () async {
setState(() {});
await loadIds();
await loadUsers();
},
icon: Icon(Icons.sync)),
),
dropdownButton(context),
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: InputDecoration(hintText: 'Поиск'),
onChanged: (v) {
if (filter != v.toLowerCase()) {
filter = v.toLowerCase();
setState(() {});
}
},
),
),
// Expanded(
// child: GestureDetector(
// onTap: () => EditUser(null, api).addCard(context),
// child: Container(
// color: Colors.green.withOpacity(0.1),
// alignment: Alignment.center,
// child: Icon(Icons.add, size: 30),
// ),
// ),
// ),
],
),
);
else if (filteredUsers.length > index - 1)
return userWidget(filteredUsers[index - 1]);
else
return null;
}),
if (selectingMode)
Align(
alignment: Alignment.bottomCenter,
child: Container(
height: 100,
padding: EdgeInsets.all(10.0),
alignment: Alignment.center,
child: Row(
children: [
GestureDetector(
onTap: () {
setState(() {});
},
child: Container(
color: Colors.blue,
padding: EdgeInsets.all(8.0),
child: Row(
children: [Icon(Icons.clear)],
),
),
),
],
),
),
),
],
),
),
);
}
Widget userWidget(UserDto user) {
void toggleSelect() {
final id = user.id.toString();
setState(() {});
}
return GestureDetector(
onTap: () {
if (selectingMode) {
toggleSelect();
} else {
EditUser(user, api).addUser(context);
}
},
onLongPress: toggleSelect,
child: _User(user, api, false),
);
}
Widget dropdownButton(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: null,
icon: _currentUser == null ? Icon(Icons.add) : Icon(Icons.edit),
),
],
);
}
}
class _User extends StatelessWidget {
final UserDto userDto;
final AdminApi api;
final bool selected;
_User(this.userDto, this.api, this.selected);
@override
Widget build(BuildContext context) {
final isCurrent = locator.userManager.userStateHolder.user?.id == userDto.id;
return ListTile(
title: Text('${userDto.id}${isCurrent ? ' (you)' : ''}'),
subtitle: Text(userDto.name ?? ''),
trailing: userDto.admin ? Text('admin') : null,
);
}
}

View file

@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
class DateParam extends StatelessWidget {
final String title;
final DateTime? initial;
final Function(DateTime? v) onChanged;
DateParam(this.title, this.initial, this.onChanged);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
Text(title),
SizedBox(
width: 8,
),
Expanded(
child: Container(
padding: EdgeInsets.all(8.0),
decoration: BoxDecoration(
border: Border.all(),
borderRadius: BorderRadius.circular(8.0),
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
final date = await showDatePicker(
context: context,
initialEntryMode: DatePickerEntryMode.calendar,
initialDate: initial ?? DateTime.now(),
firstDate: DateTime.utc(2000),
lastDate: DateTime.utc(2050),
).then((selectedDate) {
// After selecting the date, display the time picker.
if (selectedDate != null) {
return showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
).then((selectedTime) {
// Handle the selected date and time here.
if (selectedTime != null) {
DateTime selectedDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
selectedTime.hour,
selectedTime.minute,
);
return selectedDateTime;
}
return null;
});
}
return null;
});
onChanged(date);
},
child: Text(
initial?.let(
(d) =>
'${d.year}.${d.month}.${d.day} ${d.hour}:${d.minute}',
) ??
'No date',
),
),
),
),
],
),
);
}
}

View file

@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class EnumParam<T> extends StatelessWidget {
final String title;
final String? description;
final T initial;
final List<T> values;
final Function(T v) onChanged;
final Function(T v)? getName;
EnumParam(
this.title,
this.values,
this.initial,
this.onChanged, {
this.description,
this.getName,
});
String _defaultName(T v) => ((v is Enum) ? v.name : v.toString());
@override
Widget build(BuildContext context) {
T value = initial;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
if (description != null)
Text(
description!,
style: TextStyle(fontSize: 12),
)
],
),
SizedBox(
width: 2,
),
Expanded(
child: StatefulBuilder(builder: (context, setState) {
return DropdownButton<T>(
value: value,
items: values
.map(
(v) => DropdownMenuItem(
child: Text(getName?.call(v) ?? _defaultName(v)),
value: v,
),
)
.toList(),
onChanged: (v) => setState(() {
value = v!;
onChanged(v);
}),
);
}),
)
],
),
);
}
}

View file

@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class IntParam extends StatelessWidget {
final String title;
final int? initial;
final Function(int? v) onChanged;
IntParam(this.title, this.initial, this.onChanged);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Text(title+':'),
SizedBox(
width: 3,
),
Expanded(
child: TextField(
controller:
TextEditingController(text: initial?.toString() ?? ''),
onChanged: (v) => onChanged(int.tryParse(v)),
),
),
],
),
);
}
}

View file

@ -1,43 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class TextParam extends StatelessWidget {
final String title;
final String? description;
final String? initial;
final Function(String v) onChanged;
TextParam(this.title, this.initial, this.onChanged, {this.description});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
if (description != null)
Text(
description!,
style: TextStyle(fontSize: 12),
)
],
),
SizedBox(
width: 2,
),
Expanded(
child: TextField(
controller: TextEditingController(text: initial),
onChanged: onChanged,
),
),
],
),
);
}
}

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -1,25 +0,0 @@
# syntax=docker/dockerfile:1.7
# --- Build stage ------------------------------------------------------------
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Runtime stage ----------------------------------------------------------
FROM nginx:1.27-alpine AS runtime
WORKDIR /usr/share/nginx/html
# Replace default config with SPA-aware config
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ ./
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1

View file

@ -1,70 +0,0 @@
# HTTP server (SSL will be configured later)
# server {
# listen 80;
# server_name admin.mnemo-cards.online;
#
# # Redirect HTTP to HTTPS
# return 301 https://$server_name$request_uri;
# }
server {
listen 80;
server_name admin.mnemo-cards.online;
# Note: SSL certificate not yet configured - using HTTP for now
# TODO: Set up Let's Encrypt certificate for HTTPS
# ssl_certificate /etc/letsencrypt/live/admin.mnemo-cards.online/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/admin.mnemo-cards.online/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Security headers (enhanced for admin panel)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.mnemo-cards.online;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Root directory for admin panel
root /var/www/mnemo_cards_admin;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json;
# Main location block
location / {
try_files $uri $uri/ /index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1h; # Shorter cache for admin panel
add_header Cache-Control "private, must-revalidate";
}
}
# Security - deny access to hidden files and sensitive paths
location ~ /\. {
deny all;
}
location ~ ^/admin/ {
deny all; # Prevent access to any /admin/ paths that might be exposed
}
# Rate limiting for admin panel
limit_req zone=admin burst=10 nodelay;
limit_req_status 429;
# Log admin access
access_log /var/log/nginx/admin_access.log;
error_log /var/log/nginx/admin_error.log;
}
# Rate limiting zone for admin panel
limit_req_zone $binary_remote_addr zone=admin:10m rate=5r/m;

View file

@ -0,0 +1,19 @@
.git
.gitmodules
.github
.idea
.vscode
.cursor
build
.dart_tool
pubspec.lock
*.log
*.tmp
**/.DS_Store
test/
*.exe
*.dylib
*.so
zips/
backups/
apks/

View file

@ -0,0 +1,61 @@
# syntax=docker/dockerfile:1.7
# --- Build stage ------------------------------------------------------------
# Note: This Dockerfile should be built from the parent directory:
# docker build -f mnemo_cards_telegram_bot/Dockerfile -t mnemo_cards_telegram_bot .
FROM dart:stable-sdk AS build
WORKDIR /app
# Copy all local dependencies
COPY mnemo_cards_common /app/mnemo_cards_common
COPY mnemo_cards_common_backend /app/mnemo_cards_common_backend
COPY mnemo_cards_telegram_bot /app/mnemo_cards_telegram_bot
# Get dependencies (order matters due to path dependencies)
WORKDIR /app/mnemo_cards_common
RUN dart pub get
WORKDIR /app/mnemo_cards_common_backend
RUN dart pub get
WORKDIR /app/mnemo_cards_telegram_bot
RUN dart pub get
# Compile to native executable
RUN dart compile exe bin/main.dart -o /app/bot
# --- Runtime stage ----------------------------------------------------------
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Install runtime dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
zip \
&& rm -rf /var/lib/apt/lists/*
# Copy bot binary
COPY --from=build /app/bot /app/bot
# Copy required files
COPY --from=build /app/mnemo_cards_telegram_bot/admins /app/admins
# Create required directories
RUN mkdir -p /app/backups /app/zips /app/apks && \
chmod -R 755 /app/backups /app/zips /app/apks
# Environment variables
ENV BACKEND_URL=https://api.mnemo-cards.online \
BOT_SHARE_DAILY_LIMIT=1
# No EXPOSE needed - bot uses outbound connections only (polling)
# Healthcheck - check if process is running
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD pgrep -f "/app/bot" > /dev/null || exit 1
# Start bot
CMD ["/app/bot"]