hh
This commit is contained in:
parent
68e990db12
commit
0ceb254f4e
14
README.md
14
README.md
@ -37,7 +37,7 @@ After building, start the chosen bundle with `npm run start` (frontend-only) or
|
||||
To sync published funnels from MongoDB into the codebase:
|
||||
|
||||
```bash
|
||||
# Sync all published funnels from database
|
||||
# Sync all published funnels from database (keeps JSON files)
|
||||
npm run sync:funnels
|
||||
|
||||
# Preview what would be synced (dry-run mode)
|
||||
@ -46,16 +46,18 @@ npm run sync:funnels -- --dry-run
|
||||
# Sync only specific funnels
|
||||
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
|
||||
|
||||
# Keep JSON files for debugging
|
||||
npm run sync:funnels -- --keep-files
|
||||
# Sync and clean up JSON files after baking
|
||||
npm run sync:funnels -- --clean-files
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
1. Connects to MongoDB and fetches all latest published funnels
|
||||
2. Saves them as temporary JSON files in `public/funnels/`
|
||||
3. Bakes them into TypeScript (`src/lib/funnel/bakedFunnels.ts`)
|
||||
4. Cleans up temporary JSON files
|
||||
2. Downloads images from database and saves them to `public/images/`
|
||||
3. Updates image URLs in funnels to point to local files
|
||||
4. Saves them as JSON files in `public/funnels/`
|
||||
5. Bakes them into TypeScript (`src/lib/funnel/bakedFunnels.ts`)
|
||||
6. Keeps JSON files by default (use `--clean-files` to remove them)
|
||||
|
||||
### Other Funnel Commands
|
||||
|
||||
|
||||
@ -15,9 +15,9 @@ npm run import:funnels
|
||||
Синхронизирует опубликованные воронки из MongoDB обратно в проект:
|
||||
|
||||
1. Извлекает все последние версии опубликованных воронок из БД
|
||||
2. Сохраняет их во временные JSON файлы в `public/funnels/`
|
||||
2. Сохраняет их в JSON файлы в `public/funnels/`
|
||||
3. Запекает их в TypeScript (`src/lib/funnel/bakedFunnels.ts`)
|
||||
4. Удаляет временные JSON файлы
|
||||
4. Сохраняет JSON файлы по умолчанию
|
||||
|
||||
#### Основное использование:
|
||||
|
||||
@ -36,9 +36,9 @@ npm run sync:funnels -- --help
|
||||
npm run sync:funnels -- --dry-run
|
||||
```
|
||||
|
||||
**`--keep-files`** - Сохранить JSON файлы после запекания (полезно для отладки):
|
||||
**`--clean-files`** - Удалить JSON файлы после запекания (по умолчанию сохраняются):
|
||||
```bash
|
||||
npm run sync:funnels -- --keep-files
|
||||
npm run sync:funnels -- --clean-files
|
||||
```
|
||||
|
||||
**`--funnel-ids <ids>`** - Синхронизировать только определенные воронки:
|
||||
@ -49,7 +49,7 @@ npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
|
||||
**Комбинирование опций:**
|
||||
```bash
|
||||
npm run sync:funnels -- --dry-run --funnel-ids funnel-test
|
||||
npm run sync:funnels -- --keep-files --dry-run
|
||||
npm run sync:funnels -- --clean-files --dry-run
|
||||
```
|
||||
|
||||
### 🔥 `bake-funnels.mjs`
|
||||
|
||||
@ -6,7 +6,6 @@ import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
import mongoose from 'mongoose';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: '.env.local' });
|
||||
|
||||
@ -67,8 +66,7 @@ const FunnelSchema = new mongoose.Schema({
|
||||
totalViews: { type: Number, default: 0, min: 0 },
|
||||
totalCompletions: { type: Number, default: 0, min: 0 },
|
||||
lastUsed: Date
|
||||
},
|
||||
publishedAt: { type: Date, default: Date.now }
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
collection: 'funnels'
|
||||
@ -76,6 +74,124 @@ const FunnelSchema = new mongoose.Schema({
|
||||
|
||||
const Funnel = mongoose.model('Funnel', FunnelSchema);
|
||||
|
||||
// Schema for images
|
||||
const ImageSchema = new mongoose.Schema({
|
||||
filename: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
originalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
mimetype: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
data: {
|
||||
type: Buffer,
|
||||
required: true
|
||||
},
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
uploadedBy: {
|
||||
type: String,
|
||||
default: 'admin'
|
||||
},
|
||||
funnelId: {
|
||||
type: String,
|
||||
index: { sparse: true }
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
maxlength: 500
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
collection: 'images'
|
||||
});
|
||||
|
||||
const Image = mongoose.model('Image', ImageSchema);
|
||||
|
||||
async function downloadImagesFromDatabase(funnels) {
|
||||
const imagesDir = path.join(projectRoot, 'public', 'images');
|
||||
|
||||
try {
|
||||
// Создаем папку для изображений
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
console.log('📁 Created images directory');
|
||||
|
||||
// Собираем все ссылки на изображения из воронок
|
||||
const imageUrls = new Set();
|
||||
|
||||
for (const funnel of funnels) {
|
||||
for (const screen of funnel.funnelData.screens) {
|
||||
if (screen.icon?.type === 'image' && screen.icon.value?.startsWith('/api/images/')) {
|
||||
imageUrls.add(screen.icon.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.size === 0) {
|
||||
console.log('ℹ️ No images to download');
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log(`🖼️ Found ${imageUrls.size} images to download`);
|
||||
|
||||
// Скачиваем каждое изображение из БД
|
||||
const imageMapping = {};
|
||||
|
||||
for (const imageUrl of imageUrls) {
|
||||
const filename = imageUrl.replace('/api/images/', '');
|
||||
|
||||
try {
|
||||
const image = await Image.findOne({ filename }).lean();
|
||||
|
||||
if (image) {
|
||||
const localPath = path.join(imagesDir, filename);
|
||||
await fs.writeFile(localPath, image.data);
|
||||
|
||||
// Создаем маппинг: старый URL → новый локальный путь
|
||||
imageMapping[imageUrl] = `/images/${filename}`;
|
||||
console.log(`💾 Downloaded ${filename}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Image not found in database: ${filename}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error downloading ${filename}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return imageMapping;
|
||||
} catch (error) {
|
||||
console.error('❌ Error downloading images:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function updateImageUrlsInFunnels(funnels, imageMapping) {
|
||||
for (const funnel of funnels) {
|
||||
for (const screen of funnel.funnelData.screens) {
|
||||
if (screen.icon?.type === 'image' && screen.icon.value && imageMapping[screen.icon.value]) {
|
||||
const oldUrl = screen.icon.value;
|
||||
const newUrl = imageMapping[oldUrl];
|
||||
screen.icon.value = newUrl;
|
||||
console.log(`🔗 Updated image URL: ${oldUrl} → ${newUrl}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function connectDB() {
|
||||
try {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
@ -175,7 +291,7 @@ const args = process.argv.slice(2);
|
||||
const options = {
|
||||
funnelIds: [],
|
||||
dryRun: false,
|
||||
keepFiles: false,
|
||||
cleanFiles: false, // По умолчанию сохраняем файлы
|
||||
};
|
||||
|
||||
// Парсим опции
|
||||
@ -184,8 +300,8 @@ for (let i = 0; i < args.length; i++) {
|
||||
|
||||
if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
} else if (arg === '--keep-files') {
|
||||
options.keepFiles = true;
|
||||
} else if (arg === '--clean-files') {
|
||||
options.cleanFiles = true;
|
||||
} else if (arg === '--funnel-ids') {
|
||||
// Следующий аргумент должен содержать ID воронок через запятую
|
||||
const idsArg = args[++i];
|
||||
@ -200,15 +316,15 @@ Usage: npm run sync:funnels [options]
|
||||
|
||||
Options:
|
||||
--dry-run Show what would be synced without actually doing it
|
||||
--keep-files Keep JSON files after baking (useful for debugging)
|
||||
--clean-files Delete JSON files after baking (default: keep files)
|
||||
--funnel-ids <ids> Sync only specific funnel IDs (comma-separated)
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
npm run sync:funnels
|
||||
npm run sync:funnels -- --dry-run
|
||||
npm run sync:funnels # Sync all and keep JSON files
|
||||
npm run sync:funnels -- --dry-run # Preview what would be synced
|
||||
npm run sync:funnels -- --clean-files # Sync all and clean up JSON files
|
||||
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
|
||||
npm run sync:funnels -- --keep-files --dry-run
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
@ -249,7 +365,18 @@ async function syncFunnelsWithOptions() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Сохраняем каждую воронку в JSON файл
|
||||
// 4. Загружаем изображения из базы данных
|
||||
let imageMapping = {};
|
||||
if (!options.dryRun) {
|
||||
imageMapping = await downloadImagesFromDatabase(funnels);
|
||||
if (Object.keys(imageMapping).length > 0) {
|
||||
updateImageUrlsInFunnels(funnels, imageMapping);
|
||||
}
|
||||
} else {
|
||||
console.log('🔍 Would download images from database and update URLs');
|
||||
}
|
||||
|
||||
// 5. Сохраняем каждую воронку в JSON файл
|
||||
for (const funnel of funnels) {
|
||||
if (options.dryRun) {
|
||||
console.log(`🔍 Would save ${funnel.funnelData.meta.id}.json (v${funnel.version})`);
|
||||
@ -258,20 +385,23 @@ async function syncFunnelsWithOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Запекаем воронки в TypeScript
|
||||
// 6. Запекаем воронки в TypeScript
|
||||
if (!options.dryRun) {
|
||||
await bakeFunnels();
|
||||
} else {
|
||||
console.log('🔍 Would bake funnels to TypeScript');
|
||||
}
|
||||
|
||||
// 6. Удаляем JSON файлы после запекания (если не указано сохранить)
|
||||
if (!options.dryRun && !options.keepFiles) {
|
||||
// 7. Удаляем JSON файлы после запекания (только если указано)
|
||||
if (!options.dryRun && options.cleanFiles) {
|
||||
await clearFunnelsDir();
|
||||
} else if (options.keepFiles) {
|
||||
console.log('📁 Keeping JSON files as requested');
|
||||
} else if (options.dryRun) {
|
||||
console.log('🧹 Cleaned up JSON files as requested');
|
||||
} else if (!options.dryRun) {
|
||||
console.log('📁 Keeping JSON files (use --clean-files to remove them)');
|
||||
} else if (options.dryRun && options.cleanFiles) {
|
||||
console.log('🔍 Would clean up JSON files');
|
||||
} else if (options.dryRun) {
|
||||
console.log('🔍 Would keep JSON files');
|
||||
}
|
||||
|
||||
console.log('\n🎉 Funnel sync completed successfully!');
|
||||
|
||||
@ -121,20 +121,36 @@ export default function FunnelBuilderPage() {
|
||||
description: builderState.meta.description || funnelData.description,
|
||||
funnelData: updatedFunnelData,
|
||||
status: publish ? 'published' : funnelData.status,
|
||||
sessionId,
|
||||
actionDescription: publish ? 'Воронка опубликована' : 'Воронка сохранена'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка сохранения воронки');
|
||||
// Пытаемся получить детальную информацию об ошибке от API
|
||||
let errorMessage = 'Ошибка сохранения воронки';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
// Если есть детали ошибки валидации, добавляем их
|
||||
if (errorData.details) {
|
||||
errorMessage += `: ${errorData.details}`;
|
||||
}
|
||||
} catch {
|
||||
// Если не удалось распарсить JSON ошибки, используем общее сообщение
|
||||
errorMessage = `Ошибка сохранения воронки (${response.status})`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const updatedFunnel = await response.json();
|
||||
setFunnelData(updatedFunnel);
|
||||
|
||||
// Очищаем ошибку при успешном сохранении
|
||||
setError(null);
|
||||
|
||||
// Показываем уведомление об успешном сохранении
|
||||
// TODO: Добавить toast уведомления
|
||||
|
||||
return true;
|
||||
|
||||
@ -222,8 +238,8 @@ export default function FunnelBuilderPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
// Error state - только для критических ошибок загрузки
|
||||
if (error && !funnelData) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
@ -249,6 +265,35 @@ export default function FunnelBuilderPage() {
|
||||
<BuilderUndoRedoProvider>
|
||||
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
|
||||
|
||||
{/* Error Toast - показывается поверх интерфейса */}
|
||||
{error && funnelData && (
|
||||
<div className="fixed top-4 right-4 z-50 max-w-md bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Ошибка сохранения
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
<div className="mt-3 flex space-x-2">
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-sm bg-red-100 text-red-800 px-3 py-1 rounded-md hover:bg-red-200"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Bar */}
|
||||
<BuilderTopBar
|
||||
onNew={handleNew}
|
||||
|
||||
@ -177,8 +177,34 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
} catch (error) {
|
||||
console.error('PUT /api/funnels/[id] error:', error);
|
||||
|
||||
// Обработка ошибок валидации Mongoose
|
||||
if (error instanceof Error && error.name === 'ValidationError') {
|
||||
const validationError = error as Error & { errors: Record<string, { message: string }> };
|
||||
const details = [];
|
||||
|
||||
// Собираем все ошибки валидации
|
||||
for (const field in validationError.errors) {
|
||||
const fieldError = validationError.errors[field];
|
||||
details.push(`${field}: ${fieldError.message}`);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Ошибка валидации данных воронки',
|
||||
details: details.join('; ')
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Обработка других ошибок
|
||||
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка';
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update funnel' },
|
||||
{
|
||||
error: 'Ошибка сохранения воронки',
|
||||
details: errorMessage
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
114
src/app/api/images/[filename]/route.ts
Normal file
114
src/app/api/images/[filename]/route.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import { Image, type IImage } from '@/lib/models/Image';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
// Проверяем что это полная сборка (с БД)
|
||||
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image serving not available in frontend-only mode' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await connectMongoDB();
|
||||
|
||||
const { filename } = await params;
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Filename is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const image = await Image.findOne({ filename }).lean() as IImage | null;
|
||||
|
||||
if (!image) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Возвращаем изображение с правильными заголовками
|
||||
const buffer = image.data instanceof Buffer ? image.data : Buffer.from(image.data);
|
||||
|
||||
// Специальная обработка для SVG файлов
|
||||
let contentType = image.mimetype;
|
||||
if (filename.endsWith('.svg') && contentType === 'image/svg+xml') {
|
||||
contentType = 'image/svg+xml; charset=utf-8';
|
||||
}
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': buffer.length.toString(),
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Disposition': `inline; filename="${image.originalName}"`,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
// Дополнительные заголовки для SVG
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Image serving error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
// Проверяем что это полная сборка (с БД)
|
||||
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image deletion not available in frontend-only mode' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await connectMongoDB();
|
||||
|
||||
const { filename } = await params;
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Filename is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const deletedImage = await Image.findOneAndDelete({ filename });
|
||||
|
||||
if (!deletedImage) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Image deleted successfully',
|
||||
filename: deletedImage.filename
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Image deletion error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/images/route.ts
Normal file
53
src/app/api/images/route.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import { Image } from '@/lib/models/Image';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Проверяем что это полная сборка (с БД)
|
||||
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image listing not available in frontend-only mode' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await connectMongoDB();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const funnelId = searchParams.get('funnelId');
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
|
||||
const query = funnelId ? { funnelId } : {};
|
||||
|
||||
const images = await Image.find(query)
|
||||
.select('-data') // Исключаем binary данные из списка
|
||||
.sort({ uploadedAt: -1 })
|
||||
.limit(limit)
|
||||
.skip((page - 1) * limit)
|
||||
.lean();
|
||||
|
||||
const total = await Image.countDocuments(query);
|
||||
|
||||
// Добавляем URL к каждому изображению
|
||||
const imagesWithUrls = images.map(image => ({
|
||||
...image,
|
||||
url: `/api/images/${image.filename}`
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
images: imagesWithUrls,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Image listing error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
src/app/api/images/upload/route.ts
Normal file
88
src/app/api/images/upload/route.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import { Image } from '@/lib/models/Image';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Проверяем что это полная сборка (с БД)
|
||||
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image upload not available in frontend-only mode' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await connectMongoDB();
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const funnelId = formData.get('funnelId') as string || undefined;
|
||||
const description = formData.get('description') as string || undefined;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Валидация файла
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File too large. Maximum size is 5MB' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Only images are allowed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Генерируем уникальное имя файла
|
||||
const ext = file.name.split('.').pop() || 'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
|
||||
// Конвертируем файл в Buffer
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Сохраняем в БД
|
||||
const image = new Image({
|
||||
filename,
|
||||
originalName: file.name,
|
||||
mimetype: file.type,
|
||||
size: file.size,
|
||||
data: buffer,
|
||||
funnelId,
|
||||
description,
|
||||
uploadedBy: 'admin' // TODO: получать из сессии когда будет аутентификация
|
||||
});
|
||||
|
||||
await image.save();
|
||||
|
||||
// Возвращаем информацию без Buffer данных
|
||||
return NextResponse.json({
|
||||
id: image._id,
|
||||
filename: image.filename,
|
||||
originalName: image.originalName,
|
||||
mimetype: image.mimetype,
|
||||
size: image.size,
|
||||
uploadedAt: image.uploadedAt,
|
||||
url: `/api/images/${image.filename}`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Image upload error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/raw-image/route.ts
Normal file
31
src/app/api/raw-image/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import { Image } from '@/lib/models/Image';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await connectMongoDB();
|
||||
|
||||
// Получаем конкретное проблемное изображение
|
||||
const filename = 'aef03704-4188-46d0-891f-771b84a03e90.svg';
|
||||
const image = await Image.findOne({ filename }).lean();
|
||||
|
||||
if (!image) {
|
||||
return NextResponse.json({ error: 'Image not found', filename }, { status: 404 });
|
||||
}
|
||||
|
||||
// Возвращаем raw данные как text для анализа
|
||||
const buffer = Buffer.isBuffer(image.data) ? image.data : Buffer.from(image.data);
|
||||
|
||||
return new NextResponse(buffer.toString('utf8'), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Raw image error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/test-image/route.ts
Normal file
42
src/app/api/test-image/route.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import { Image, type IImage } from '@/lib/models/Image';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await connectMongoDB();
|
||||
|
||||
// Получаем конкретное проблемное изображение
|
||||
const filename = 'aef03704-4188-46d0-891f-771b84a03e90.svg';
|
||||
const image = await Image.findOne({ filename }).lean() as any;
|
||||
|
||||
if (!image) {
|
||||
return NextResponse.json({ message: 'Image not found', filename });
|
||||
}
|
||||
|
||||
// Проверяем начало данных изображения
|
||||
const buffer = Buffer.isBuffer(image.data) ? image.data : Buffer.from(image.data);
|
||||
const first100Chars = buffer.slice(0, 100).toString('utf8');
|
||||
const first100Bytes = Array.from(buffer.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
|
||||
// Возвращаем детальную информацию об изображении
|
||||
return NextResponse.json({
|
||||
filename: image.filename,
|
||||
originalName: image.originalName,
|
||||
mimetype: image.mimetype,
|
||||
size: image.size,
|
||||
dataType: typeof image.data,
|
||||
dataLength: image.data ? image.data.length : 'null',
|
||||
isBuffer: Buffer.isBuffer(image.data),
|
||||
actualBufferLength: buffer.length,
|
||||
first100Chars,
|
||||
first100Bytes,
|
||||
isValidSvg: first100Chars.includes('<svg'),
|
||||
url: `/api/images/${image.filename}`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -82,7 +82,7 @@ export function TemplateSummary({ screen }: TemplateSummaryProps) {
|
||||
case "info": {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
{screen.description?.text && <p>{screen.description.text}</p>}
|
||||
{screen.subtitle?.text && <p>{screen.subtitle.text}</p>}
|
||||
{screen.icon?.value && (
|
||||
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
|
||||
<span className="text-base">{screen.icon.value}</span>
|
||||
|
||||
@ -63,7 +63,20 @@ export function BuilderSidebar() {
|
||||
};
|
||||
|
||||
const handleScreenIdChange = (currentId: string, newId: string) => {
|
||||
if (newId.trim() === "" || newId === currentId) {
|
||||
if (newId === currentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Разрешаем пустые ID для полного переименования
|
||||
if (newId.trim() === "") {
|
||||
// Просто обновляем на пустое значение, пользователь сможет ввести новое
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId: currentId,
|
||||
screen: { id: newId }
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
304
src/components/admin/builder/forms/ImageUpload.tsx
Normal file
304
src/components/admin/builder/forms/ImageUpload.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TextInput } from '@/components/ui/TextInput/TextInput';
|
||||
import { Upload, X, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
|
||||
interface UploadedImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
interface ImageUploadProps {
|
||||
currentValue?: string;
|
||||
onImageSelect: (url: string) => void;
|
||||
onImageRemove: () => void;
|
||||
funnelId?: string;
|
||||
}
|
||||
|
||||
export function ImageUpload({
|
||||
currentValue,
|
||||
onImageSelect,
|
||||
onImageRemove,
|
||||
funnelId
|
||||
}: ImageUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
|
||||
const [showGallery, setShowGallery] = useState(false);
|
||||
const [isLoadingGallery, setIsLoadingGallery] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadImages = useCallback(async () => {
|
||||
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
|
||||
return; // В frontend режиме загрузка недоступна
|
||||
}
|
||||
|
||||
setIsLoadingGallery(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (funnelId) params.append('funnelId', funnelId);
|
||||
params.append('limit', '20');
|
||||
|
||||
const response = await fetch(`/api/images?${params}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUploadedImages(data.images);
|
||||
} else {
|
||||
console.error('Failed to load images');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading images:', error);
|
||||
} finally {
|
||||
setIsLoadingGallery(false);
|
||||
}
|
||||
}, [funnelId]);
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
|
||||
setError('Загрузка изображений недоступна в frontend режиме');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (funnelId) formData.append('funnelId', funnelId);
|
||||
|
||||
const response = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const uploadedImage = await response.json();
|
||||
onImageSelect(uploadedImage.url);
|
||||
setUploadedImages(prev => [uploadedImage, ...prev]);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || 'Ошибка загрузки файла');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Произошла ошибка при загрузке файла');
|
||||
console.error('Upload error:', error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
handleFileUpload(file);
|
||||
} else {
|
||||
setError('Пожалуйста, выберите файл изображения');
|
||||
}
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
setShowGallery(true);
|
||||
loadImages();
|
||||
};
|
||||
|
||||
const isFullMode = process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT !== 'frontend';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Текущее изображение */}
|
||||
{currentValue && (
|
||||
<div className="relative">
|
||||
<div className="rounded-lg border border-border p-2 bg-muted/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{currentValue.startsWith('/api/images/') ? 'Загруженное изображение' : currentValue}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onImageRemove}
|
||||
className="h-6 w-6 p-0 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!currentValue && (
|
||||
<div className="space-y-3">
|
||||
{/* URL Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Ссылка на изображение
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder="https://example.com/image.jpg"
|
||||
value=""
|
||||
onChange={(e) => onImageSelect(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload Section (только в full режиме) */}
|
||||
{isFullMode && (
|
||||
<>
|
||||
<div className="text-center text-sm text-muted-foreground">или</div>
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-6 text-center transition-colors
|
||||
${dragActive
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}
|
||||
${isUploading ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Загрузка...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Перетащите изображение сюда или нажмите для выбора
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, JPG, GIF, WebP до 5MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openGallery}
|
||||
disabled={isUploading}
|
||||
className="w-full"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4 mr-2" />
|
||||
Выбрать из загруженных
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded p-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery Modal */}
|
||||
{showGallery && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-background rounded-lg p-6 max-w-2xl max-h-[80vh] overflow-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Выберите изображение</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowGallery(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingGallery ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : uploadedImages.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{uploadedImages.map((image, index) => (
|
||||
<div
|
||||
key={image.filename || `image-${index}`}
|
||||
className="relative aspect-square border border-border rounded cursor-pointer hover:bg-muted/50 overflow-hidden"
|
||||
onClick={() => {
|
||||
onImageSelect(image.url);
|
||||
setShowGallery(false);
|
||||
}}
|
||||
>
|
||||
{/* Используем обычный img для тестирования */}
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.originalName}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
console.error('Image load error:', image.url, e);
|
||||
// Показываем placeholder
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Image loaded successfully:', image.url);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/75 text-white text-xs p-1 truncate">
|
||||
{image.originalName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Пока нет загруженных изображений
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
|
||||
import { MarkupPreview } from "@/components/ui/MarkupText/MarkupText";
|
||||
import { ImageUpload } from "@/components/admin/builder/forms/ImageUpload";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
@ -14,16 +13,6 @@ interface InfoScreenConfigProps {
|
||||
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
||||
const infoScreen = screen as InfoScreenDefinition;
|
||||
|
||||
const handleDescriptionChange = (text: string) => {
|
||||
onUpdate({
|
||||
description: text
|
||||
? {
|
||||
...(infoScreen.description ?? {}),
|
||||
text,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleIconChange = <T extends keyof NonNullable<InfoScreenDefinition["icon"]>>(
|
||||
field: T,
|
||||
@ -40,34 +29,18 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// При изменении типа иконки сбрасываем значение
|
||||
if (field === "type") {
|
||||
const defaultValue = value === "emoji" ? "✨" : "";
|
||||
onUpdate({ icon: { ...baseIcon, type: value as "emoji" | "image", value: defaultValue } });
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate({ icon: { ...baseIcon, [field]: value } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Информационный контент
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
|
||||
<TextAreaInput
|
||||
placeholder="Введите пояснение для пользователя. Используйте **текст** для выделения жирным."
|
||||
value={infoScreen.description?.text ?? ""}
|
||||
onChange={(event) => handleDescriptionChange(event.target.value)}
|
||||
rows={3}
|
||||
className="resize-y"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */}
|
||||
{infoScreen.description?.text && (
|
||||
<MarkupPreview text={infoScreen.description.text} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Иконка</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
@ -99,16 +72,30 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{infoScreen.icon?.type === "image" ? "Ссылка на изображение" : "Emoji символ"}
|
||||
</span>
|
||||
<TextInput
|
||||
placeholder={infoScreen.icon?.type === "image" ? "https://..." : "Например, ✨"}
|
||||
value={infoScreen.icon?.value ?? ""}
|
||||
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
|
||||
/>
|
||||
</label>
|
||||
{infoScreen.icon?.type === "image" ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground mb-2 block">
|
||||
Изображение иконки
|
||||
</span>
|
||||
<ImageUpload
|
||||
currentValue={infoScreen.icon?.value}
|
||||
onImageSelect={(url) => handleIconChange("value", url)}
|
||||
onImageRemove={() => handleIconChange("value", undefined)}
|
||||
funnelId={screen.id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Emoji символ
|
||||
</span>
|
||||
<TextInput
|
||||
placeholder="Например, ✨"
|
||||
value={infoScreen.icon?.value ?? ""}
|
||||
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
import { buildTypographyProps } from "@/lib/funnel/mappers";
|
||||
import type { InfoScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "../layouts/TemplateLayout";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -40,6 +38,79 @@ export function InfoTemplate({
|
||||
}
|
||||
}, [screen.icon?.size]);
|
||||
|
||||
// Функция для проверки валидности URL
|
||||
const isValidUrl = (value: string): boolean => {
|
||||
if (!value || value.trim() === '') return false;
|
||||
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
// Проверяем относительные пути (начинаются с /) и API пути
|
||||
return value.startsWith('/') || value.startsWith('/api/');
|
||||
}
|
||||
};
|
||||
|
||||
// Создаем иконку для передачи в childrenAboveTitle
|
||||
const iconElement = screen.icon ? (
|
||||
<div className={cn("mb-8", screen.icon.className)}>
|
||||
{screen.icon.type === "emoji" ? (
|
||||
<div className={cn(iconSizeClasses, "leading-none")}>
|
||||
{screen.icon.value}
|
||||
</div>
|
||||
) : (screen.icon.value && isValidUrl(screen.icon.value)) ? (
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={screen.icon.value}
|
||||
alt=""
|
||||
width={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
height={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
className={cn("object-contain")}
|
||||
unoptimized={screen.icon.value.startsWith('/api/images/')}
|
||||
onError={(e) => {
|
||||
console.error('Preview image load error:', screen.icon?.value, e);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Preview image loaded successfully:', screen.icon?.value);
|
||||
}}
|
||||
/>
|
||||
{/* Fallback для проблемных изображений */}
|
||||
<img
|
||||
src={screen.icon.value}
|
||||
alt=""
|
||||
className={cn("absolute inset-0 object-contain opacity-0 hover:opacity-100")}
|
||||
style={{
|
||||
width: iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36,
|
||||
height: iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('Fallback image load error:', screen.icon?.value, e);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Fallback image loaded successfully:', screen.icon?.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(iconSizeClasses, "leading-none text-muted-foreground flex items-center justify-center")}>
|
||||
📷
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
@ -53,66 +124,16 @@ export function InfoTemplate({
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
childrenAboveTitle={iconElement}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-full flex flex-col items-center justify-center text-center",
|
||||
screen.icon ? "mt-[60px]" : "-mt-[20px]"
|
||||
)}>
|
||||
{/* Icon */}
|
||||
{screen.icon && (
|
||||
<div className={cn("mb-8", screen.icon.className)}>
|
||||
{screen.icon.type === "emoji" ? (
|
||||
<div className={cn(iconSizeClasses, "leading-none")}>
|
||||
{screen.icon.value}
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src={screen.icon.value}
|
||||
alt=""
|
||||
width={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
height={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
className={cn("object-contain")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{screen.description && (
|
||||
<div className={cn(
|
||||
"max-w-[280px]",
|
||||
screen.icon ? "mt-6" : "mt-0"
|
||||
)}>
|
||||
<Typography
|
||||
as="p"
|
||||
font="inter"
|
||||
weight="medium"
|
||||
color="default"
|
||||
size="lg"
|
||||
align="center"
|
||||
{...buildTypographyProps(screen.description, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "default",
|
||||
align: "center",
|
||||
},
|
||||
})}
|
||||
className={cn("leading-[26px]", screen.description.className)}
|
||||
>
|
||||
{screen.description.text}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
{/* Пустые дети - весь контент теперь в заголовке, подзаголовке и иконке */}
|
||||
<div className="w-full flex justify-center">
|
||||
<div className={cn(
|
||||
"w-full max-w-[320px] text-center",
|
||||
screen.icon ? "mt-[30px]" : "mt-[60px]"
|
||||
)}>
|
||||
{/* Дополнительный контент если нужен */}
|
||||
</div>
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
|
||||
@ -14,19 +14,18 @@ const mockScreen: InfoScreenDefinition = {
|
||||
},
|
||||
title: {
|
||||
text: "TemplateLayout Demo",
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "center",
|
||||
size: "2xl",
|
||||
color: "default",
|
||||
},
|
||||
description: {
|
||||
subtitle: {
|
||||
show: true,
|
||||
text: "Это демонстрация **TemplateLayout** - централизованного layout wrapper для всех funnel templates. Он управляет header, progress bar, кнопкой назад и нижней кнопкой.",
|
||||
font: "inter",
|
||||
weight: "regular",
|
||||
align: "center",
|
||||
size: "md",
|
||||
color: "default",
|
||||
align: "center",
|
||||
},
|
||||
bottomActionButton: {
|
||||
show: true,
|
||||
|
||||
@ -42,6 +42,9 @@ interface TemplateLayoutProps {
|
||||
childrenAboveButton?: React.ReactNode;
|
||||
childrenUnderButton?: React.ReactNode;
|
||||
|
||||
// Дополнительные props для Title
|
||||
childrenAboveTitle?: React.ReactNode;
|
||||
|
||||
// Контент template
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@ -60,6 +63,7 @@ export function TemplateLayout({
|
||||
actionButtonOptions,
|
||||
childrenAboveButton,
|
||||
childrenUnderButton,
|
||||
childrenAboveTitle,
|
||||
children,
|
||||
}: TemplateLayoutProps) {
|
||||
// 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON
|
||||
@ -117,7 +121,7 @@ export function TemplateLayout({
|
||||
// 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ
|
||||
return (
|
||||
<div className="w-full">
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<LayoutQuestion {...layoutQuestionProps} childrenAboveTitle={childrenAboveTitle}>
|
||||
{children}
|
||||
</LayoutQuestion>
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ export interface LayoutQuestionProps
|
||||
children: React.ReactNode;
|
||||
contentProps?: React.ComponentProps<"div">;
|
||||
childrenWrapperProps?: React.ComponentProps<"div">;
|
||||
childrenAboveTitle?: React.ReactNode; // Контент над заголовком
|
||||
}
|
||||
|
||||
function LayoutQuestion({
|
||||
@ -22,6 +23,7 @@ function LayoutQuestion({
|
||||
children,
|
||||
contentProps,
|
||||
childrenWrapperProps,
|
||||
childrenAboveTitle,
|
||||
...props
|
||||
}: LayoutQuestionProps) {
|
||||
return (
|
||||
@ -43,6 +45,9 @@ function LayoutQuestion({
|
||||
contentProps?.className
|
||||
)}
|
||||
>
|
||||
{/* Контент над заголовком */}
|
||||
{childrenAboveTitle}
|
||||
|
||||
{title && (
|
||||
<Typography
|
||||
as="h2"
|
||||
|
||||
@ -4,7 +4,6 @@ import {
|
||||
buildDefaultTitle,
|
||||
buildDefaultBottomActionButton,
|
||||
buildDefaultNavigation,
|
||||
buildDefaultDescription,
|
||||
buildDefaultSubtitle
|
||||
} from "./blocks";
|
||||
|
||||
@ -18,13 +17,15 @@ export function buildInfoDefaults(id: string): BuilderScreen {
|
||||
align: "center",
|
||||
}),
|
||||
subtitle: buildDefaultSubtitle({
|
||||
show: false,
|
||||
text: undefined,
|
||||
}),
|
||||
description: buildDefaultDescription({
|
||||
text: "Добавьте описание для информационного экрана. Используйте **жирный текст** для выделения важного.",
|
||||
show: true,
|
||||
text: "Добавьте подзаголовок для информационного экрана",
|
||||
align: "center",
|
||||
}),
|
||||
icon: {
|
||||
type: "emoji" as const,
|
||||
value: "ℹ️",
|
||||
size: "xl" as const,
|
||||
},
|
||||
bottomActionButton: buildDefaultBottomActionButton(),
|
||||
navigation: buildDefaultNavigation(),
|
||||
} as BuilderScreen;
|
||||
|
||||
@ -38,6 +38,11 @@ export function buildTypographyProps<T extends TypographyAs>(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Проверяем поле show - если false, не показываем
|
||||
if ('show' in variant && variant.show === false) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { as, defaults } = options;
|
||||
|
||||
return {
|
||||
@ -193,12 +198,9 @@ export function buildLayoutQuestionProps(
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
} : undefined,
|
||||
title: screen.title ? (buildTypographyProps(screen.title, {
|
||||
title: screen.title ? buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: titleDefaults,
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
}) : undefined,
|
||||
subtitle: 'subtitle' in screen ? buildTypographyProps(screen.subtitle, {
|
||||
as: "p",
|
||||
|
||||
@ -129,7 +129,7 @@ export interface InfoScreenDefinition {
|
||||
template: "info";
|
||||
header?: HeaderDefinition;
|
||||
title: TitleDefinition;
|
||||
description?: TypographyVariant;
|
||||
subtitle?: SubtitleDefinition;
|
||||
icon?: IconDefinition;
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
|
||||
71
src/lib/models/Image.ts
Normal file
71
src/lib/models/Image.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface IImage {
|
||||
_id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
data: Buffer;
|
||||
uploadedAt: Date;
|
||||
uploadedBy: string;
|
||||
funnelId?: string; // Связь с воронкой для возможной очистки
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const ImageSchema = new mongoose.Schema<IImage>({
|
||||
filename: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
originalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
mimetype: {
|
||||
type: String,
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(v: string) {
|
||||
return /^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i.test(v);
|
||||
},
|
||||
message: 'Only image files are allowed'
|
||||
}
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
required: true,
|
||||
max: 5 * 1024 * 1024 // 5MB максимум
|
||||
},
|
||||
data: {
|
||||
type: Buffer,
|
||||
required: true
|
||||
},
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
uploadedBy: {
|
||||
type: String,
|
||||
default: 'admin'
|
||||
},
|
||||
funnelId: {
|
||||
type: String,
|
||||
index: { sparse: true } // Индекс только для не-null значений
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
maxlength: 500
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
collection: 'images'
|
||||
});
|
||||
|
||||
// Дополнительные индексы для производительности
|
||||
ImageSchema.index({ uploadedAt: -1 });
|
||||
|
||||
export const Image = mongoose.models.Image || mongoose.model<IImage>('Image', ImageSchema);
|
||||
Loading…
Reference in New Issue
Block a user