first commit

This commit is contained in:
brusnitsyn
2025-10-31 16:48:05 +09:00
commit 8b650558e2
143 changed files with 24664 additions and 0 deletions

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

65
.env.example Normal file
View File

@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=psql
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

1
README.md Normal file
View File

@@ -0,0 +1 @@
# documenter-mono

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,289 @@
<?php
namespace App\Http\Controllers;
use App\Models\DocumentTemplate;
use App\Services\DocxParser;
use App\Services\DocxVariableExtractor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use PhpOffice\PhpWord\IOFactory;
class DocImportController extends Controller
{
public function show($id, Request $request)
{
$template = DocumentTemplate::findOrFail($id);
$urlFile = \Storage::temporaryUrl($template->source_path, now()->addMinutes(2));
return response()->json([
'id' => $template->id,
'name' => $template->name,
'description' => $template->description,
'file_url' => $urlFile,
'variables' => $template->variables,
]);
}
public function update(Request $request)
{
$data = $request->validate([
'id' => 'required|numeric',
'file' => 'nullable|file|mimes:docx|max:10240',
'name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'variables' => 'nullable|array',
]);
$template = DocumentTemplate::findOrFail($data['id']);
if ($request->hasFile('file')) {
$dirName = pathinfo($template->source_path, PATHINFO_DIRNAME);
$fileName = pathinfo($template->source_path, PATHINFO_BASENAME);
$file = $request->file('file');
$file->move("$dirName", $fileName);
$template->update([
'source_path' => "$dirName/$fileName",
]);
}
$template->update([
'name' => $data['name'],
'description' => $data['description'],
'variables' => $data['variables'],
]);
}
public function store(Request $request, DocxParser $parser)
{
$data = $request->validate([
'file' => 'required|file|mimes:docx|max:10240',
'name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'variables' => 'nullable|array',
]);
$file = $request->file('file');
$templateFolderName = md5(uniqid(rand(), true));
$templateFileName = 'source.' . $file->getClientOriginalExtension();
$laravelPath = 'templates/' . $templateFolderName;
$file->move("storage/$laravelPath", $templateFileName);
$template = DocumentTemplate::create([
'name' => $data['name'] ?? 'Тест',
'description' => $data['description'],
'content' => 'content',
'variables' => $data['variables'] ?? [],
'source_path' => "storage/$laravelPath" . '/' . $templateFileName,
]);
}
private function cleanHtml($html) : string
{
// Убираем ненужные теги и атрибуты
$html = preg_replace('/<!DOCTYPE[^>]*>/', '', $html);
$html = preg_replace('/<html[^>]*>/', '', $html);
$html = preg_replace('/<\/html>/', '', $html);
$html = preg_replace('/<head>.*?<\/head>/si', '', $html);
$html = preg_replace('/<body[^>]*>/', '', $html);
$html = preg_replace('/<\/body>/', '', $html);
// Убираем пустые теги и лишние пробелы
$html = preg_replace('/<p[^>]*>(\s|&nbsp;)*<\/p>/', '', $html);
$html = preg_replace('/<br\s*\/?>\s*<br\s*\/?>/', '<br>', $html);
// Очищаем ссылки ConsultantPlus
$html = preg_replace('/<a[^>]*consultantplus[^>]*>([^<]*)<\/a>/', '$1', $html);
// Упрощаем теги font
$html = preg_replace('/<font[^>]*face="([^"]*)"[^>]*>/', '<span style="font-family: $1">', $html);
// $html = preg_replace('/<font[^>]*size="([^"]*)"[^>]*>/', '<span style="font-size: $1pt">', $html);
$html = preg_replace('/<font[^>]*color="([^"]*)"[^>]*>/', '<span style="color: $1">', $html);
$html = str_replace('</font>', '</span>', $html);
// Обрабатываем вложенные font теги
$html = preg_replace('/<span[^>]*><span[^>]*>/', '<span>', $html);
$html = preg_replace('/<\/span><\/span>/', '</span>', $html);
// Заменяем закладки и якоря
$html = preg_replace('/<a name="[^"]*"><\/a>/', '', $html);
// Обрабатываем шаблонные переменные {{ }}
$html = preg_replace('/<span lang="en-US"><b>\{\{<\/b><\/span>/', '{{', $html);
$html = preg_replace('/<span lang="ru-RU"><b>([^<]*)<\/b><\/span><span lang="en-US"><b>\}\}<\/b><\/span>/', '$1}}', $html);
// Улучшаем таблицы
$html = preg_replace('/<table[^>]*>/', '<table class="docx-table">', $html);
$html = preg_replace('/<td[^>]*>/', '<td class="docx-td">', $html);
$html = preg_replace('/<th[^>]*>/', '<th class="docx-th">', $html);
// Убираем лишние классы western
$html = str_replace('class="western"', '', $html);
// Стили для красивого отображения
$styles = '
<style>
.libreoffice-preview {
font-family: "Times New Roman", serif;
line-height: 1.2;
padding: 2cm 1.5cm 2cm 3cm;
background: white;
max-width: 210mm;
margin: 0 auto;
color: #00000a;
font-size: 12pt;
}
.libreoffice-preview p {
// margin: 12px 0;
text-align: justify;
}
.libreoffice-preview p.align-center {
text-align: center;
}
.libreoffice-preview p.align-left {
text-align: left;
}
.libreoffice-preview p.align-right {
text-align: right;
}
.libreoffice-preview b, .libreoffice-preview strong {
font-weight: bold;
}
.libreoffice-preview i, .libreoffice-preview em {
font-style: italic;
}
.libreoffice-preview u {
text-decoration: underline;
}
/* Таблицы */
.docx-table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
border: 1px solid #000000;
}
.docx-td, .docx-th {
border: 1px solid #000000;
padding: 8px 12px;
vertical-align: top;
}
.docx-th {
background-color: #f5f5f5;
font-weight: bold;
text-align: center;
}
/* Заголовки */
.libreoffice-preview h1, .libreoffice-preview h2, .libreoffice-preview h3 {
text-align: center;
margin: 20px 0;
font-weight: bold;
}
/* Отступы для списков */
.libreoffice-preview ul, .libreoffice-preview ol {
margin: 10px 0;
padding-left: 30px;
}
.libreoffice-preview li {
margin: 5px 0;
}
/* Шаблонные переменные */
.template-var {
background-color: #fff3cd;
border: 1px dashed #ffc107;
padding: 2px 4px;
border-radius: 3px;
font-weight: bold;
color: #856404;
}
/* Подписи */
.signature {
margin-top: 40px;
border-top: 1px solid #000;
padding-top: 10px;
}
/* Реквизиты */
.requisites {
font-size: 10pt;
line-height: 1.4;
}
/* Отступы для абзацев с отступами */
.indent-1 { text-indent: 1.25cm; }
.indent-095 { text-indent: 0.95cm; }
.indent-1cm { text-indent: 1cm; }
/* Отступы */
.margin-bottom-035 { margin-bottom: 0.35cm; }
.margin-top-021 { margin-top: 0.21cm; }
</style>
';
// Добавляем классы для выравнивания
$html = preg_replace('/<p[^>]*align="center"[^>]*>/', '<p class="align-center">', $html);
$html = preg_replace('/<p[^>]*align="left"[^>]*>/', '<p class="align-left">', $html);
$html = preg_replace('/<p[^>]*align="right"[^>]*>/', '<p class="align-right">', $html);
// Добавляем классы для отступов
$html = preg_replace('/style="[^"]*text-indent:\s*1\.25cm[^"]*"/', 'class="indent-1"', $html);
$html = preg_replace('/style="[^"]*text-indent:\s*0\.95cm[^"]*"/', 'class="indent-095"', $html);
$html = preg_replace('/style="[^"]*text-indent:\s*1cm[^"]*"/', 'class="indent-1cm"', $html);
// Обрабатываем шаблонные переменные
$html = preg_replace('/\{\{([^}]+)\}\}/', '<span class="template-var">{{$1}}</span>', $html);
return '<div class="libreoffice-preview">' . $html . '</div>' . $styles;
}
public function previewVariables(Request $request, DocxVariableExtractor $extractor)
{
$rules = [
'name' => 'required|string',
'doc_file' => 'required|file|mimes:docx,doc|max:10240',
];
$messages = [
'name.required' => 'Наименование не может быть пустым',
'doc_file.required' => 'Вы не приложили документ',
];
$validator = \Validator::make($request->all(), $rules, $messages);
$validator->validate();
try {
$file = $request->file('doc_file');
$tempPath = $file->getRealPath();
$variables = $extractor->extractVariables($tempPath);
return response()->json([
'success' => true,
'variables' => $variables,
'count' => count($variables)
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Ошибка при анализе файла: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use App\Models\DocumentTemplate;
use App\Services\DocxParser;
use App\Services\PageBreaker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Inertia\Inertia;
use PhpOffice\PhpWord\IOFactory;
class DocumentController extends Controller
{
public function show($id, Request $request)
{
$template = DocumentTemplate::find($id);
return Inertia::render('ContractGenerator', [
'template' => $template
]);
}
/**
* Предпросмотр документа с подставленными значениями
*/
public function preview(Request $request, $id)
{
$request->validate([
'variables' => 'nullable|array'
]);
try {
$template = DocumentTemplate::findOrFail($id);
// Генерируем DOCX с подставленными значениями
$docxPath = $template->generateDocument($request->variables);
// Конвертируем в PDF
$pdfPath = $template->convertToPdf($docxPath);
// Регистрируем функцию для удаления после завершения
register_shutdown_function(function () use ($docxPath, $pdfPath) {
File::delete($docxPath);
File::delete($pdfPath);
});
// Отдаем PDF
return response()->file($pdfPath, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="preview.pdf"'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage()
], 500);
}
}
/**
* Скачивание готового документа
*/
public function download(Request $request, $id)
{
$request->validate([
'variables' => 'nullable|array'
]);
try {
$template = DocumentTemplate::findOrFail($id);
$docxPath = $template->generateDocument($request->variables);
return response()->download($docxPath,
$template->name . '.docx',
[
'Content-Type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition' => 'attachment; filename="' . $template->name . '.docx"'
]
);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage()
], 500);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\DocumentTemplate;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DocumentEditorController extends Controller
{
public function editor(Request $request)
{
$templateId = $request->get('templateId', null);
$template = null;
if ($templateId !== null) {
$template = DocumentTemplate::find($templateId);
}
return Inertia::render('TemplateEditor', [
'template' => $template,
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use App\Models\DocumentTemplate;
use Illuminate\Http\Request;
class EditorController extends Controller
{
public function save(Request $request)
{
$template = DocumentTemplate::find($request->get('id'));
$data = [
'content' => $request->get('content'),
'variables_config' => $request->get('variables_config'),
];
$template->update($data);
return redirect()->back();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Models\DocumentTemplate;
use Illuminate\Http\Request;
use Inertia\Inertia;
class WorkspaceController extends Controller
{
public function showTemplates()
{
$activeTemplates = DocumentTemplate::all();
return Inertia::render('Index', [
'templates' => $activeTemplates
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
//
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Services\DocxTemplateProcessor;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class DocumentTemplate extends Model
{
protected $fillable = [
'name',
'description',
'content',
'variables',
'source_path'
];
protected $casts = [
'variables' => 'array'
];
/**
* Подстановка значений в DOCX шаблон
*/
public function generateDocument($data)
{
$tempDir = storage_path('app/temp/' . uniqid());
mkdir($tempDir, 0755, true);
// Копируем исходный шаблон
$templatePath = $tempDir . '/template.docx';
copy($this->source_path, $templatePath);
$docx = new DocxTemplateProcessor();
// Подставляем значения
$changedDocxPath = $docx->processWithPhpWord($templatePath, $data);
// Возвращаем путь к сгенерированному файлу
return $changedDocxPath;
}
/**
* Конвертация в PDF для предпросмотра
*/
public function convertToPdf($docxPath)
{
$pdfPath = str_replace('.docx', '.pdf', $docxPath);
// Надо добавить....
$home = config('libreoffice.home');
$user = config('libreoffice.user');
putenv("HOME=$home");
putenv("USER=$user");
$command = "libreoffice --headless --convert-to pdf --outdir " .
escapeshellarg(dirname($pdfPath)) . " " .
escapeshellarg($docxPath);
$result = shell_exec($command . " 2>&1");
if (!file_exists($pdfPath)) {
throw new \Exception("PDF conversion failed: " . $result);
}
return $pdfPath;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DocumentTemplateVariable extends Model
{
//
}

48
app/Models/User.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

644
app/Services/DocxParser.php Normal file
View File

@@ -0,0 +1,644 @@
<?php
namespace App\Services;
use PhpOffice\PhpWord\IOFactory;
use PhpOffice\PhpWord\PhpWord;
//use DOMDocument;
use Exception;
class DocxParser
{
public function parse($filePath)
{
try {
$phpWord = IOFactory::load($filePath);
$sections = $phpWord->getSections();
$template = [
'metadata' => [
'title' => basename($filePath, '.docx'),
'created_at' => now()->toISOString(),
'source' => 'docx',
'total_pages' => 0
],
'structure' => []
];
$sectionElements = [];
foreach ($sections as $sectionIndex => $section) {
foreach ($section->getElements() as $elementIndex => $element) {
// $sectionElements[] = $this->parseElement($element);
array_push($sectionElements, $this->parseElement($element, $elementIndex));
}
}
$template['structure']['elements'] = $sectionElements;
// dd($template);
// Извлекаем переменные
$template['variables_config'] = $this->extractVariables($template['structure']);
return $template;
} catch (Exception $e) {
\Log::error("Ошибка парсинга DOCX: " . $e->getMessage(), $e->getTrace());
}
}
private function parseElement($element, $elementIndex = 0)
{
$parsedElements = [];
$elementType = get_class($element);
switch ($elementType) {
case 'PhpOffice\PhpWord\Element\Text':
$parsedElements = $this->parseTextElement($element, $elementIndex++);
break;
case 'PhpOffice\PhpWord\Element\TextRun':
$textRunElements = $this->parseTextRun($element, $elementIndex);
// Получаем стиль параграфа для TextRun
$paragraphStyle = $element->getParagraphStyle();
// Если TextRun содержит только один элемент, добавляем его напрямую
if (count($textRunElements) === 1) {
$textRunElements[0]['style'] = $this->parseParagraphStyle($paragraphStyle);
$isHeading = $this->isTextRunHeading($textRunElements[0]);
$textRunElements[0]['is_heading'] = $isHeading;
$textRunElements[0]['type'] = $isHeading ? 'heading' : 'paragraph';
$parsedElements = $textRunElements[0];
$elementIndex++;
} else {
// Иначе создаем контейнер TextRun
$parsedElements = [
'id' => 'textrun-' . $elementIndex++,
'type' => 'text_run',
'elements' => $textRunElements,
'style' => $this->parseParagraphStyle($paragraphStyle)
// 'formatting' => $this->getTextRunFormatting($element)
];
}
break;
case 'PhpOffice\PhpWord\Element\TextBreak':
$parsedElements = [
'id' => 'break-' . $elementIndex++,
'type' => 'line_break',
'content' => '<br>'
];
break;
case 'PhpOffice\PhpWord\Element\Title':
$parsedElements = $this->parseTitleElement($element, $elementIndex++);
break;
case 'PhpOffice\PhpWord\Element\Table':
$parsedElements = $this->parseTableElement($element, $elementIndex++);
break;
// case 'PhpOffice\PhpWord\Element\Image':
// $parsedElements[] = $this->parseImageElement($element, $elementIndex++);
// break;
default:
// Пропускаем неизвестные элементы
break;
}
return $parsedElements;
}
private function getTextRunFormatting($textRun)
{
$style = $textRun->getParagraphStyle();
return $this->parseStyle($style);
}
private function parseTextElement($element, $index)
{
$text = $element->getText();
$style = $element->getFontStyle();
// Ищем переменные в тексте (например: {{variable}} или [VARIABLE])
$variables = $this->extractVariablesFromText($text);
return [
'id' => 'element-' . $index,
'type' => empty($variables) ? 'paragraph' : 'variable',
'content' => $text,
'formatting' => $this->parseStyle($style),
'variables' => $variables,
'is_inline' => true
];
}
public function isTextRunHeading($element) {
if ($element['formatting'] && $element['style']) {
if ($element['formatting']['bold'] && $element['style']['align'] === 'center') {
return true;
}
}
return false;
}
public function parseParagraphStyle($style)
{
if (!$style) return null;
$paragraphStyle = [];
// Выравнивание
if ($style->getAlignment()) {
$alignment = $style->getAlignment();
$paragraphStyle['align'] = $this->mapAlignment($alignment);
}
// Отступы
if ($style->getIndentation()) {
$indentation = $style->getIndentation();
$paragraphStyle['indent'] = [
'left' => $indentation->getLeft(),
'right' => $indentation->getRight(),
'firstLine' => ($indentation->getFirstLine() / 1440) * 96, //\PhpOffice\PhpWord\Shared\Converter::twip($indentation->getFirstLine())
];
}
// dd($style->getLineHeight());
// Междустрочный интервал
if ($style->getLineHeight()) {
$paragraphStyle['lineHeight'] = $style->getLineHeight();
}
// Интервалы до и после
if ($style->getSpaceBefore()) {
$paragraphStyle['spaceBefore'] = $style->getSpaceBefore();
}
if ($style->getSpaceAfter()) {
$paragraphStyle['spaceAfter'] = $style->getSpaceAfter();
}
// Табуляция
if ($style->getTabs()) {
$paragraphStyle['tabs'] = $style->getTabs();
}
return !empty($paragraphStyle) ? $paragraphStyle : null;
}
private function mapAlignment($alignment)
{
$mapping = [
\PhpOffice\PhpWord\SimpleType\Jc::START => 'left',
\PhpOffice\PhpWord\SimpleType\Jc::END => 'right',
\PhpOffice\PhpWord\SimpleType\Jc::CENTER => 'center',
\PhpOffice\PhpWord\SimpleType\Jc::BOTH => 'justify',
\PhpOffice\PhpWord\SimpleType\Jc::DISTRIBUTE => 'distribute'
];
return $mapping[$alignment] ?? 'left';
}
public function parseTextRun($textRun, $startIndex)
{
$elements = [];
$index = $startIndex;
foreach ($textRun->getElements() as $runElement) {
if (get_class($runElement) === 'PhpOffice\PhpWord\Element\Text') {
$elements[] = $this->parseTextElement($runElement, $index++);
}
}
return $elements;
}
private function parseTitleElement($element, $index)
{
$text = $element->getText();
$level = $element->getDepth() + 1;
return [
'id' => 'heading-' . $index,
'type' => 'heading',
'level' => $level,
'content' => $text,
'formatting' => [
'bold' => true,
'fontSize' => $this->getHeadingSize($level)
]
];
}
private function parseTableElement($table, $index)
{
$rows = [];
$rowIndex = 0;
$tableIndex = $index;
foreach ($table->getRows() as $row) {
$cells = [];
$cellIndex = 0;
foreach ($row->getCells() as $cell) {
$styles = $this->getCellStyles($cell);
$cellElements = [];
foreach ($cell->getElements() as $cellElement) {
$cellElements[] = $this->parseElement($cellElement, $index);
$index++;
}
$cells[] = [
'id' => 'cell-' . $rowIndex . '-' . $cellIndex,
'elements' => $cellElements,
// 'variables' => $this->extra($cellElements),
'style' => $styles,
'width' => ($cell->getWidth() / 1440) * 96
];
$cellIndex++;
}
$rows[] = [
'id' => 'row-' . $rowIndex,
'cells' => $cells
];
$rowIndex++;
}
return [
'id' => 'table-' . $tableIndex,
'type' => 'table',
'rows' => $rows,
'cols' => count($rows[0]['cells'] ?? []),
// 'content' => $this->generateTableHTML($rows)
];
}
private function getCellStyles($cell)
{
$style = $cell->getStyle();
return [
'borderTopSize' => $style->getBorderTopSize(),
'borderTopColor' => $style->getBorderTopColor(),
'borderTopStyle' => $style->getBorderTopStyle(),
'borderLeftSize' => $style->getBorderLeftSize(),
'borderLeftColor' => $style->getBorderLeftColor(),
'borderLeftStyle' => $style->getBorderLeftStyle(),
'borderRightSize' => $style->getBorderRightSize(),
'borderRightColor' => $style->getBorderRightColor(),
'borderRightStyle' => $style->getBorderRightStyle(),
'borderBottomSize' => $style->getBorderBottomSize(),
'borderBottomColor' => $style->getBorderBottomColor(),
'borderBottomStyle' => $style->getBorderBottomStyle(),
];
}
private function parseImageElement($element, $index)
{
return [
'id' => 'image-' . $index,
'type' => 'image',
'src' => $element->getSource(),
'width' => $element->getWidth(),
'height' => $element->getHeight(),
'alt' => $element->getAlt() ?? 'Image'
];
}
private function parseStyle($style)
{
if (!$style) return [];
return [
'bold' => $style->isBold(),
'italic' => $style->isItalic(),
'underline' => $style->getUnderline(),
'fontSize' => $style->getSize(),
'fontColor' => $style->getColor(),
'fontFamily' => $style->getName()
];
}
// Формирование HTML
public function generateParagraphStyle($paragraphStyle)
{
if (empty($paragraphStyle)) {
return '';
}
$styles = [];
// Выравнивание
if (!empty($paragraphStyle['align'])) {
$styles[] = "text-align: {$paragraphStyle['align']}";
}
// Междустрочный интервал
if (!empty($paragraphStyle['lineHeight'])) {
$lineHeight = $paragraphStyle['lineHeight'];
if (is_numeric($lineHeight)) {
$styles[] = "line-height: {$lineHeight}";
} else {
$styles[] = "line-height: {$lineHeight}";
}
}
// Отступы
if (!empty($paragraphStyle['spaceBefore'])) {
$styles[] = "margin-top: {$paragraphStyle['spaceBefore']}pt";
}
if (!empty($paragraphStyle['spaceAfter'])) {
$styles[] = "margin-bottom: {$paragraphStyle['spaceAfter']}pt";
}
// Отступы первой строки
if (!empty($paragraphStyle['indent'])) {
$indent = $paragraphStyle['indent'];
if (!empty($indent['left'])) {
$styles[] = "margin-left: {$indent['left']}pt";
}
if (!empty($indent['right'])) {
$styles[] = "margin-right: {$indent['right']}pt";
}
if (!empty($indent['firstLine'])) {
$styles[] = "text-indent: {$indent['firstLine']}pt";
}
}
// Табуляция
if (!empty($paragraphStyle['tabs'])) {
$tabStops = [];
foreach ($paragraphStyle['tabs'] as $tab) {
if (!empty($tab['position'])) {
$position = $tab['position'];
$type = $tab['type'] ?? 'left';
$tabStops[] = "{$position}pt {$type}";
}
}
if (!empty($tabStops)) {
$styles[] = "tab-stops: " . implode(', ', $tabStops);
}
}
return implode('; ', $styles);
}
public function generateTextRunStyle($formatting)
{
if (empty($formatting)) {
return '';
}
$styles = [];
// Размер шрифта
if (!empty($formatting['fontSize'])) {
$fontSize = $formatting['fontSize'];
if (is_numeric($fontSize)) {
$styles[] = "font-size: {$fontSize}pt";
} else {
$styles[] = "font-size: {$fontSize}";
}
}
// Цвет шрифта
if (!empty($formatting['fontColor'])) {
$styles[] = "color: {$formatting['fontColor']}";
}
// Цвет фона
if (!empty($formatting['backgroundColor'])) {
$styles[] = "background-color: {$formatting['backgroundColor']}";
}
// Шрифт
if (!empty($formatting['fontFamily'])) {
$styles[] = "font-family: '{$formatting['fontFamily']}'";
}
// Жирный текст
if (!empty($formatting['bold']) && $formatting['bold']) {
$styles[] = "font-weight: bold";
}
// Курсив
if (!empty($formatting['italic']) && $formatting['italic']) {
$styles[] = "font-style: italic";
}
// Подчеркивание
if (!empty($formatting['underline']) && $formatting['underline']) {
$styles[] = "text-decoration: underline";
}
// Зачеркивание
if (!empty($formatting['strikethrough']) && $formatting['strikethrough']) {
$styles[] = "text-decoration: line-through";
}
// Надстрочный индекс
if (!empty($formatting['superscript']) && $formatting['superscript']) {
$styles[] = "vertical-align: super";
$styles[] = "font-size: smaller";
}
// Подстрочный индекс
if (!empty($formatting['subscript']) && $formatting['subscript']) {
$styles[] = "vertical-align: sub";
$styles[] = "font-size: smaller";
}
// Тень текста
if (!empty($formatting['shadow']) && $formatting['shadow']) {
$styles[] = "text-shadow: 1px 1px 2px rgba(0,0,0,0.3)";
}
// Трансформация текста
if (!empty($formatting['textTransform'])) {
$styles[] = "text-transform: {$formatting['textTransform']}";
}
// Межбуквенный интервал
if (!empty($formatting['letterSpacing'])) {
$styles[] = "letter-spacing: {$formatting['letterSpacing']}pt";
}
// Межсловный интервал
if (!empty($formatting['wordSpacing'])) {
$styles[] = "word-spacing: {$formatting['wordSpacing']}pt";
}
return implode('; ', $styles);
}
public function replaceVariables($content, $formData)
{
return $content;
if (empty($content)) {
return $content;
}
// Заменяем плейсхолдеры вида {{variable}}
foreach ($formData as $key => $value) {
if (!empty($value)) {
$content = str_replace("{{{$key}}}", $value, $content);
$content = str_replace("[[{$key}]]", $value, $content);
$content = str_replace("__{$key}__", $value, $content);
}
}
// Заменяем span-плейсхолдеры с data-variable
$dom = new DOMDocument();
@$dom->loadHTML('<?xml encoding="UTF-8"><div>' . $content . '</div>');
$xpath = new DOMXPath($dom);
$placeholders = $xpath->query('//*[@data-variable]');
foreach ($placeholders as $placeholder) {
$variableName = $placeholder->getAttribute('data-variable');
$value = $formData[$variableName] ?? '';
if (!empty($value)) {
// Создаем текстовый узел с значением
$textNode = $dom->createTextNode($value);
$placeholder->parentNode->replaceChild($textNode, $placeholder);
}
}
// Извлекаем HTML обратно
$html = $dom->saveHTML();
$html = preg_replace('/^<!DOCTYPE.*?>\n?/', '', $html);
$html = preg_replace('/<\?xml encoding="UTF-8"\?>\n?/', '', $html);
$html = preg_replace('/^<div>/', '', $html);
$html = preg_replace('/<\/div>$/', '', $html);
return trim($html);
}
private function extractVariablesFromText($text)
{
$variables = [];
// Ищем шаблоны переменных: {{variable}}, [VARIABLE], ${variable}, etc.
$patterns = [
'/\{\{(\w+)\}\}/',
'/\[(\w+)\]/',
'/\$(\w+)/',
'/%(\w+)%/'
];
foreach ($patterns as $pattern) {
if (preg_match_all($pattern, $text, $matches)) {
foreach ($matches[1] as $match) {
$variables[] = $match;
}
}
}
return array_unique($variables);
}
public function extractVariables($structure)
{
$variables = [];
foreach ($structure['elements'] as $element) {
if (!empty($element['variables'])) {
foreach ($element['variables'] as $varName) {
if (!isset($variables[$varName])) {
$variables[$varName] = [
'type' => 'text',
'label' => $this->formatVariableLabel($varName),
'default' => ''
];
}
}
}
if (!array_key_exists('type', $element)) dd($element);
// Для таблиц
if ($element['type'] === 'table' && !empty($element['rows'])) {
foreach ($element['rows'] as $row) {
foreach ($row['cells'] as $cell) {
if (!empty($cell['variables'])) {
foreach ($cell['variables'] as $varName) {
if (!isset($variables[$varName])) {
$variables[$varName] = [
'type' => 'text',
'label' => $this->formatVariableLabel($varName),
'default' => ''
];
}
}
}
}
}
}
}
return $variables;
}
private function formatVariableLabel($varName)
{
return ucfirst(str_replace(['_', '-'], ' ', $varName));
}
private function getHeadingSize($level)
{
$sizes = [
1 => '24pt',
2 => '20pt',
3 => '18pt',
4 => '16pt',
5 => '14pt',
6 => '12pt'
];
return $sizes[$level] ?? '16pt';
}
private function generateTableHTML($rows)
{
$html = '<table style="width: 100%; border-collapse: collapse; margin: 1em 0;">';
foreach ($rows as $row) {
$html .= '<tr>';
foreach ($row['cells'] as $cell) {
$html .= '<td style="border: 1px solid #000; padding: 8px;">';
$html .= htmlspecialchars($cell['content']);
$html .= '</td>';
}
$html .= '</tr>';
}
$html .= '</table>';
return $html;
}
// Метод для обработки загруженного файла
public function parseUploadedFile($uploadedFile)
{
$tempPath = $uploadedFile->getRealPath();
$extension = $uploadedFile->getClientOriginalExtension();
if ($extension !== 'docx') {
throw new Exception("Поддерживаются только файлы .docx");
}
return $this->parse($tempPath);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services;
use PhpOffice\PhpWord\TemplateProcessor;
class DocxTemplateProcessor
{
/**
* Подстановка значений в DOCX файл
*/
public function processWithPhpWord($templatePath, $data): string
{
try {
$templateProcessor = new TemplateProcessor($templatePath);
foreach ($data as $value) {
if (array_key_exists('value', $value)) {
if (isset($value['value'])) {
$templateProcessor->setValue($value['name'], $value['value']);
} else {
$templateProcessor->setValue($value['value'], $value['value']);
}
}
}
$outputPath = storage_path('app/temp/' . uniqid() . '.docx');
$templateProcessor->saveAs($outputPath);
return $outputPath;
} finally {
$this->cleanupTemplate($templatePath);
}
}
protected function cleanupTemplate($templatePath): void
{
try {
if (file_exists($templatePath)) {
unlink($templatePath);
}
$templateDir = dirname($templatePath);
if (is_dir($templateDir) && $this->isDirEmpty($templateDir)) {
rmdir($templateDir);
}
} catch (\Exception $e) {
\Log::warning("Template cleanup warning: " . $e->getMessage());
}
}
protected function isDirEmpty($dir): bool
{
return count(scandir($dir)) == 2; // только . и ..
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
use PhpOffice\PhpWord\IOFactory;
class DocxVariableExtractor
{
public function extractVariables($docxPath): array
{
$phpWord = IOFactory::load($docxPath);
$variables = [];
$textBuffer = '';
foreach ($phpWord->getSections() as $section) {
$this->extractFromSection($section, $variables, $textBuffer);
}
// Проверяем остаток в буфере
$this->extractFromText($textBuffer, $variables);
// Убираем дубликаты по полю 'name'
$uniqueVariables = [];
$seenNames = [];
foreach ($variables as $variable) {
if (!in_array($variable['name'], $seenNames)) {
$uniqueVariables[] = $variable;
$seenNames[] = $variable['name'];
}
}
return $uniqueVariables;
}
private function extractFromSection($section, array &$variables, string &$textBuffer): void
{
foreach ($section->getElements() as $element) {
$this->extractFromElement($element, $variables, $textBuffer);
}
}
private function extractFromElement($element, array &$variables, string &$textBuffer): void
{
$elementType = get_class($element);
switch ($elementType) {
case 'PhpOffice\PhpWord\Element\TextRun':
$this->extractFromTextRun($element, $variables, $textBuffer);
break;
case 'PhpOffice\PhpWord\Element\Text':
$textBuffer .= $element->getText();
$this->extractFromText($textBuffer, $variables);
break;
case 'PhpOffice\PhpWord\Element\Table':
$this->extractFromTable($element, $variables, $textBuffer);
break;
case 'PhpOffice\PhpWord\Element\Header':
case 'PhpOffice\PhpWord\Element\Footer':
$this->extractFromSection($element, $variables, $textBuffer);
break;
default:
// Сбрасываем буфер при смене типа элемента
$textBuffer = '';
break;
}
}
private function extractFromTextRun($textRun, array &$variables, string &$textBuffer): void
{
foreach ($textRun->getElements() as $element) {
if ($element instanceof \PhpOffice\PhpWord\Element\Text) {
$textBuffer .= $element->getText();
}
}
// Проверяем буфер после каждого TextRun
$this->extractFromText($textBuffer, $variables);
$textBuffer = ''; // Сбрасываем буфер после TextRun
}
private function extractFromTable($table, array &$variables, string &$textBuffer): void
{
foreach ($table->getRows() as $row) {
foreach ($row->getCells() as $cell) {
$cellBuffer = '';
foreach ($cell->getElements() as $element) {
if ($element instanceof \PhpOffice\PhpWord\Element\Text) {
$cellBuffer .= $element->getText();
} elseif ($element instanceof \PhpOffice\PhpWord\Element\TextRun) {
foreach ($element->getElements() as $textElement) {
if ($textElement instanceof \PhpOffice\PhpWord\Element\Text) {
$cellBuffer .= $textElement->getText();
}
}
}
}
$this->extractFromText($cellBuffer, $variables);
}
}
}
private function extractFromText(string $text, array &$variables): void
{
// Ищем переменные в формате ${variable_name}
preg_match_all('/\$\{\s*([a-zA-Zа-яА-ЯёЁ0-9_]+)\s*\}/u', $text, $matches);
if (!empty($matches[1])) {
foreach ($matches[0] as $index => $fullMatch) {
$variables[] = [
'name' => $fullMatch, // Полное выражение с ${}
'label' => $matches[1][$index], // Только содержимое внутри {}
'type' => 'text'
];
}
}
}
// Альтернативный метод: чтение напрямую из XML
public function extractVariablesFromXml($docxPath): array
{
$variables = [];
// Временная распаковка DOCX
$zip = new \ZipArchive();
if ($zip->open($docxPath) === TRUE) {
// Читаем document.xml
$documentXml = $zip->getFromName('word/document.xml');
if ($documentXml) {
// Ищем все текстовые узлы
preg_match_all('/(\{\{\s*[a-zA-Zа-яА-ЯёЁ0-9_]+\s*\}\})/u', $documentXml, $matches);
foreach ($matches[0] as $match) {
// Извлекаем имя переменной
preg_match('/\{\{\s*([a-zA-Zа-яА-ЯёЁ0-9_]+)\s*\}\}/u', $match, $varMatches);
if (isset($varMatches[1])) {
$variables[] = $varMatches[1];
}
}
}
// Также проверяем headers и footers
for ($i = 0; $i < $zip->numFiles; $i++) {
$filename = $zip->getNameIndex($i);
if (preg_match('/word\/header\d+\.xml/', $filename) ||
preg_match('/word\/footer\d+\.xml/', $filename)) {
$content = $zip->getFromName($filename);
preg_match_all('/(\{\{\s*[a-zA-Zа-яА-ЯёЁ0-9_]+\s*\}\})/u', $content, $matches);
foreach ($matches[0] as $match) {
preg_match('/\{\{\s*([a-zA-Zа-яА-ЯёЁ0-9_]+)\s*\}\}/u', $match, $varMatches);
if (isset($varMatches[1])) {
$variables[] = $varMatches[1];
}
}
}
}
$zip->close();
}
return array_unique($variables);
}
// Комбинированный метод
public function extractVariablesCombined($docxPath): array
{
$variables1 = $this->extractVariables($docxPath);
$variables2 = $this->extractVariablesFromXml($docxPath);
return array_unique(array_merge($variables1, $variables2));
}
public function validateVariables(array $variables): array
{
$validated = [];
foreach ($variables as $variable) {
if (preg_match('/^[a-zA-Zа-яА-ЯёЁ0-9_]+$/u', $variable)) {
$validated[] = $variable;
}
}
return array_unique($validated);
}
public function getVariablesWithExamples($docxPath): array
{
$variables = $this->extractVariablesCombined($docxPath);
$result = [];
foreach ($variables as $variable) {
$result[$variable] = [
'name' => $variable,
'human_name' => $this->makeHumanReadable($variable),
'example' => $this->generateExample($variable),
'type' => $this->detectType($variable)
];
}
return $result;
}
private function makeHumanReadable(string $variable): string
{
$readable = str_replace('_', ' ', $variable);
$readable = mb_strtolower($readable, 'UTF-8');
$readable = mb_convert_case($readable, MB_CASE_TITLE, 'UTF-8');
return $readable;
}
private function generateExample(string $variable): string
{
$examples = [
'name' => 'Иван Иванов',
'date' => '15.01.2024',
'number' => '123-2024',
'company' => 'ООО "Рога и копыта"',
'address' => 'г. Москва, ул. Примерная, д. 1',
'amount' => '100 000 руб.',
'quantity' => '5',
'price' => '20 000 руб.'
];
foreach ($examples as $key => $example) {
if (stripos($variable, $key) !== false) {
return $example;
}
}
return 'Пример значения';
}
private function detectType(string $variable): string
{
if (stripos($variable, 'date') !== false) return 'date';
if (stripos($variable, 'amount') !== false || stripos($variable, 'price') !== false) return 'money';
if (stripos($variable, 'quantity') !== false || stripos($variable, 'number') !== false) return 'number';
if (stripos($variable, 'list') !== false || stripos($variable, 'items') !== false) return 'array';
return 'text';
}
}

View File

@@ -0,0 +1,239 @@
<?php
namespace App\Services;
use DOMDocument;
use DOMXPath;
class PageBreaker
{
private $maxPageHeight; // Максимальная высота страницы в px
private $currentPageHeight;
private $pages;
private $currentPageContent;
public function __construct($maxPageHeight = 1122) // 29.7cm * 37.8px/cm ≈ 1122px
{
$this->maxPageHeight = $maxPageHeight;
$this->currentPageHeight = 0;
$this->pages = [];
$this->currentPageContent = '';
}
public function splitIntoPages($htmlContent)
{
if (empty($htmlContent)) {
return [['content' => '', 'pageNumber' => 1]];
}
$dom = new DOMDocument();
@$dom->loadHTML('<?xml encoding="UTF-8"><div id="content">' . $htmlContent . '</div>');
$this->pages = [];
$this->currentPageHeight = 0;
$this->currentPageContent = '';
$body = $dom->getElementById('content');
if ($body) {
$this->processNode($body);
}
// Добавляем последнюю страницу
if (!empty($this->currentPageContent)) {
$this->addPage();
}
return $this->pages;
}
private function processNode($node)
{
$nodeName = strtolower($node->nodeName);
// Элементы, которые нельзя разрывать
$unbreakableElements = ['table', 'tr', 'img', 'pre', 'code'];
if (in_array($nodeName, $unbreakableElements)) {
$this->processUnbreakableElement($node);
} else {
$this->processBreakableElement($node);
}
}
private function processUnbreakableElement($node)
{
$elementHtml = $this->getOuterHTML($node);
$estimatedHeight = $this->estimateElementHeight($elementHtml);
// Если элемент не помещается на текущую страницу
if ($this->currentPageHeight + $estimatedHeight > $this->maxPageHeight && $this->currentPageHeight > 0) {
$this->addPage();
}
$this->currentPageContent .= $elementHtml;
$this->currentPageHeight += $estimatedHeight;
}
private function processBreakableElement($node)
{
if ($node->nodeType === XML_TEXT_NODE) {
$this->processTextNode($node);
return;
}
// Для breakable элементов обрабатываем детей по отдельности
foreach ($node->childNodes as $child) {
$this->processNode($child);
}
// Добавляем закрывающий тег после обработки всех детей
if ($node->nodeType === XML_ELEMENT_NODE) {
$this->currentPageContent .= '</' . $node->nodeName . '>';
}
}
private function processTextNode($node)
{
$text = $node->textContent;
$words = preg_split('/(\s+)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
foreach ($words as $word) {
if (trim($word) === '') {
$this->addContent($word, 4); // Примерная высота пробела
continue;
}
$wordHeight = $this->estimateTextHeight($word);
// Если слово не помещается на текущую страницу
if ($this->currentPageHeight + $wordHeight > $this->maxPageHeight && $this->currentPageHeight > 0) {
$this->addPage();
}
$this->addContent($word, $wordHeight);
}
}
private function addContent($content, $height)
{
$this->currentPageContent .= $content;
$this->currentPageHeight += $height;
}
private function addPage()
{
$this->pages[] = [
'content' => $this->currentPageContent,
'pageNumber' => count($this->pages) + 1,
'height' => $this->currentPageHeight
];
$this->currentPageContent = '';
$this->currentPageHeight = 0;
}
private function estimateElementHeight($html)
{
// Упрощенная оценка высоты элемента
$lineHeight = 20; // Примерная высота строки в px
$lines = substr_count($html, '<br') + substr_count($html, '</p') + substr_count($html, '</div') + 1;
return $lines * $lineHeight;
}
private function estimateTextHeight($text)
{
// Оценка высоты текста based на количество символов
$avgCharWidth = 8; // Средняя ширина символа в px
$lineHeight = 20; // Высота строки в px
$maxWidth = 500; // Максимальная ширина контента в px
$estimatedWidth = strlen($text) * $avgCharWidth;
$lines = ceil($estimatedWidth / $maxWidth);
return $lines * $lineHeight;
}
private function getOuterHTML($node)
{
$doc = new DOMDocument();
$doc->appendChild($doc->importNode($node, true));
return $doc->saveHTML();
}
// Альтернативный метод: разделение по количеству символов
public function splitByCharacterCount($htmlContent, $charsPerPage = 3000)
{
$pages = [];
$currentPage = '';
$charCount = 0;
// Разделяем HTML на теги и текст
$tokens = preg_split('/(<[^>]+>)/', $htmlContent, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
$openTags = [];
foreach ($tokens as $token) {
// Если это открывающий тег
if (preg_match('/^<([^\/][^>]*)>$/', $token, $matches)) {
$tagName = strtolower(explode(' ', $matches[1])[0]);
$openTags[] = $tagName;
$currentPage .= $token;
}
// Если это закрывающий тег
elseif (preg_match('/^<\/([^>]+)>$/', $token, $matches)) {
array_pop($openTags);
$currentPage .= $token;
}
// Если это текст
else {
$text = $token;
$textLength = mb_strlen($text);
if ($charCount + $textLength > $charsPerPage && $charCount > 0) {
// Закрываем открытые теги
$currentPage .= $this->closeOpenTags($openTags);
$pages[] = [
'content' => $currentPage,
'pageNumber' => count($pages) + 1
];
$currentPage = $this->reopenTags($openTags) . $text;
$charCount = $textLength;
} else {
$currentPage .= $text;
$charCount += $textLength;
}
}
}
// Добавляем последнюю страницу
if (!empty($currentPage)) {
$pages[] = [
'content' => $currentPage,
'pageNumber' => count($pages) + 1
];
}
return $pages;
}
private function closeOpenTags($openTags)
{
$html = '';
for ($i = count($openTags) - 1; $i >= 0; $i--) {
$html .= '</' . $openTags[$i] . '>';
}
return $html;
}
private function reopenTags($openTags)
{
$html = '';
foreach ($openTags as $tag) {
$html .= '<' . $tag . '>';
}
return $html;
}
}

18
artisan Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

23
bootstrap/app.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Sentry\Laravel\Integration;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
Integration::handles($exceptions);
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

5
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

79
composer.json Normal file
View File

@@ -0,0 +1,79 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"phpoffice/phpword": "^1.4",
"sentry/sentry-laravel": "^4.17"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9155
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

115
config/auth.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

108
config/cache.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

174
config/database.php Normal file
View File

@@ -0,0 +1,174 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
];

80
config/filesystems.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

11
config/libreoffice.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return [
/*
* putenv('HOME=/tmp');
* putenv('USER=www-data');
*
*/
'home' => env('LIBREOFFICE_HOME', '/tmp'),
'user' => env('LIBREOFFICE_USER', 'www-data')
];

132
config/logging.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

112
config/queue.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

84
config/sanctum.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

135
config/sentry.php Normal file
View File

@@ -0,0 +1,135 @@
<?php
/**
* Sentry Laravel SDK configuration file.
*
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
*/
return [
// @see https://docs.sentry.io/product/sentry-basics/dsn-explainer/
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
// @see https://spotlightjs.com/
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
// The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
'release' => env('SENTRY_RELEASE'),
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
'environment' => env('SENTRY_ENVIRONMENT'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles-sample-rate
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
'logs_channel_level' => env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug')),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
// 'ignore_exceptions' => [],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
'ignore_transactions' => [
// Ignore Laravel's default health URL
'/up',
],
// Breadcrumb specific configuration
'breadcrumbs' => [
// Capture Laravel logs as breadcrumbs
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true),
// Capture Livewire components like routes as breadcrumbs
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
// Capture SQL queries as breadcrumbs
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
// Capture queue job information as breadcrumbs
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
// Capture command information as breadcrumbs
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
// Capture HTTP client request information as breadcrumbs
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture send notifications as breadcrumbs
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
],
// Performance monitoring specific configuration
'tracing' => [
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
// Capture queue jobs as spans when executed on the sync driver
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
// Capture SQL queries as spans
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query spans
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
// Capture where the SQL query originated from on the SQL query spans
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
// Define a threshold in milliseconds for SQL queries to resolve their origin
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
// Capture views rendered as spans
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
// Capture Livewire components as spans
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
// Capture HTTP client requests as spans
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as spans
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
// Capture Redis operations as spans (this enables Redis events in Laravel)
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
// Capture where the Redis command originated from on the Redis command spans
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
// Capture send notifications as spans
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
// Enable tracing for requests without a matching route (404's)
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
// Enable the tracing integrations supplied by Sentry (recommended)
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
],
];

38
config/services.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'resend' => [
'key' => env('RESEND_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

217
config/session.php Normal file
View File

@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug(env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('document_templates', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('description')->nullable();
$table->longText('content');
$table->json('variables');
$table->string('source_path')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_templates');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use App\Models\DocumentTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('document_template_variables', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(DocumentTemplate::class, 'document_template_id');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_template_variables');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@@ -0,0 +1,23 @@
<?php
namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace Database\Seeders;
use App\Models\DocumentTemplate;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DocumentTemplateSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
DocumentTemplate::create([
'name' => 'Договор подряда (упрощенный)',
'content' => [
'metadata' => [
'title' => 'Договор подряда',
'version' => '1.0'
],
'structure' => [
[
'id' => 'header',
'type' => 'section',
'enabled' => true,
'elements' => [
[
'id' => 'header_title',
'type' => 'heading',
'content' => 'ДОГОВОР ПОДРЯДА № <span class="placeholder" data-variable="contract_number">[номер]</span>'
],
[
'id' => 'header_date',
'type' => 'html',
'content' => '<table style="width: 100%; border: none; margin: 20px 0;">
<tr>
<td style="width: 50%; border: none;">г. <span class="placeholder" data-variable="city">[город]</span></td>
<td style="width: 50%; border: none; text-align: right;">«<span class="placeholder" data-variable="day">[день]</span>» <span class="placeholder" data-variable="month">[месяц]</span> <span class="placeholder" data-variable="year">[год]</span> г.</td>
</tr>
</table>',
'variables' => [
[
'name' => 'city',
'type' => 'select',
'label' => 'Город',
'options' => [
'Москва' => 'г. Москва',
'Санкт-Петербург' => 'г. Санкт-Петербург',
'Новосибирск' => 'г. Новосибирск',
'Екатеринбург' => 'г. Екатеринбург'
],
'default' => 'Москва'
],
'day', 'month', 'year' // простые текстовые поля
]
]
]
],
[
'id' => 'preamble',
'type' => 'section',
'enabled' => true,
'elements' => [
[
'id' => 'preamble_title',
'type' => 'heading',
'content' => '1. ПРЕАМБУЛА'
],
[
'id' => 'preamble_text',
'type' => 'html',
'content' => '<p><span class="placeholder" data-variable="client_type">[Тип заказчика]</span> <span class="placeholder" data-variable="client_name">[Ф.И.О. или наименование Заказчика]</span>, именуемый в дальнейшем «Заказчик», с одной стороны, и <span class="placeholder" data-variable="contractor_type">[Тип подрядчика]</span> <span class="placeholder" data-variable="contractor_name">[Ф.И.О. или наименование Подрядчика]</span>, именуемый в дальнейшем «Подрядчик», с другой стороны, заключили настоящий договор о нижеследующем:</p>',
'variables' => [
[
'name' => 'client_type',
'type' => 'select',
'label' => 'Тип заказчика',
'options' => [
'Индивидуальный предприниматель' => 'Индивидуальный предприниматель',
'Общество с ограниченной ответственностью' => 'Общество с ограниченной ответственностью',
'Физическое лицо' => 'Физическое лицо'
],
'default' => 'Индивидуальный предприниматель'
],
'client_name',
[
'name' => 'contractor_type',
'type' => 'select',
'label' => 'Тип подрядчика',
'options' => [
'Индивидуальный предприниматель' => 'Индивидуальный предприниматель',
'Общество с ограниченной ответственностью' => 'Общество с ограниченной ответственностью',
'Физическое лицо' => 'Физическое лицо'
],
'default' => 'Индивидуальный предприниматель'
],
'contractor_name'
]
]
]
],
[
'id' => 'subject',
'type' => 'section',
'enabled' => true,
'elements' => [
[
'id' => 'subject_title',
'type' => 'heading',
'content' => '2. ПРЕДМЕТ ДОГОВОРА'
],
[
'id' => 'subject_text',
'type' => 'html',
'content' => '<p>2.1. Подрядчик обязуется выполнить следующие работы: <span class="placeholder" data-variable="work_type">[вид работ]</span>, а Заказчик обязуется принять результат работ и оплатить его.</p>',
'variables' => [
[
'name' => 'work_type',
'type' => 'select',
'label' => 'Вид работ',
'options' => [
'ремонтные' => 'ремонтные работы',
'строительные' => 'строительные работы',
'отделочные' => 'отделочные работы',
'монтажные' => 'монтажные работы',
'проектные' => 'проектные работы'
],
'default' => 'ремонтные'
]
]
],
[
'id' => 'subject_details',
'type' => 'html',
'content' => '<p>2.2. Подробное описание работ: <span class="placeholder" data-variable="work_description">[описание работ]</span>.</p>',
'variables' => ['work_description']
]
]
],
[
'id' => 'payment',
'type' => 'section',
'enabled' => true,
'elements' => [
[
'id' => 'payment_title',
'type' => 'heading',
'content' => '3. СТОИМОСТЬ РАБОТ И ПОРЯДОК РАСЧЕТОВ'
],
[
'id' => 'payment_text',
'type' => 'html',
'content' => '<p>3.1. Стоимость работ составляет: <span class="placeholder" data-variable="work_price">[сумма]</span> рублей.</p>',
'variables' => ['work_price']
],
[
'id' => 'payment_method',
'type' => 'html',
'content' => '<p>3.2. Форма оплаты: <span class="placeholder" data-variable="payment_method">[форма оплаты]</span>.</p>',
'variables' => [
[
'name' => 'payment_method',
'type' => 'radio',
'label' => 'Форма оплаты',
'options' => [
'cash' => 'Наличный расчет',
'non-cash' => 'Безналичный расчет',
'advance' => 'Авансовый платеж'
],
'default' => 'non-cash'
]
]
]
]
],
[
'id' => 'signatures',
'type' => 'section',
'enabled' => true,
'elements' => [
[
'id' => 'signatures_table',
'type' => 'html',
'content' => '<table style="width: 100%; border-collapse: collapse; margin-top: 40px;">
<tr>
<td style="width: 50%; border: none; vertical-align: top;">
<strong>ЗАКАЗЧИК:</strong><br>
<span class="placeholder" data-variable="client_name">[Наименование/Ф.И.О.]</span><br>
Подпись: ________________ / <span class="placeholder" data-variable="client_signature">[Ф.И.О.]</span> /
</td>
<td style="width: 50%; border: none; vertical-align: top;">
<strong>ПОДРЯДЧИК:</strong><br>
<span class="placeholder" data-variable="contractor_name">[Наименование/Ф.И.О.]</span><br>
Подпись: ________________ / <span class="placeholder" data-variable="contractor_signature">[Ф.И.О.]</span> /
</td>
</tr>
</table>',
'variables' => ['client_name', 'client_signature', 'contractor_name', 'contractor_signature']
]
]
]
]
]
]);
}
}

53
nginx.dev.conf Normal file
View File

@@ -0,0 +1,53 @@
server {
# Enable Gzip
gzip on;
gzip_http_version 1.0;
gzip_comp_level 2;
gzip_min_length 1100;
gzip_buffers 4 8k;
gzip_proxied any;
gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/json application/xml application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
gzip_static on;
gzip_proxied expired no-cache no-store private auth;
gzip_disable "MSIE [1-6]\.";
gzip_vary on;
listen 30;
listen [::]:30;
server_name documenter.dev.ru;
ssl_certificate /home/user/ssl/certificate.crt;
ssl_certificate_key /home/user/ssl/certificate.key;
root /documenter-mono/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
access_log /home/user/logs/documenter-mono/nginx.access.log;
error_log /home/user/logs/documenter-mono/nginx.error.log;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# error_page 404 /index.php;
location ~ \.php$ {
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}

4312
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.12",
"@vitejs/plugin-vue": "^6.0.1",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.12",
"vite": "^7.0.4",
"vite-plugin-vue-devtools": "^8.0.3"
},
"dependencies": {
"@heroicons/vue": "^2.2.0",
"@inertiajs/vue3": "^2.1.3",
"@vueuse/core": "^13.9.0",
"date-fns": "^4.1.0",
"vue": "^3.5.20",
"vue-pdf-embed": "^2.1.3"
}
}

34
phpunit.xml Normal file
View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
public/.htaccess Normal file
View File

@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

0
public/favicon.ico Normal file
View File

20
public/index.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

62
resources/css/app.css Normal file
View File

@@ -0,0 +1,62 @@
@import 'tailwindcss';
@reference "tailwindcss";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@plugin 'tailwind-scrollbar';
@font-face {
font-family: "Golos Sans";
font-weight: 400;
src: url("/assets/fonts/Golos-Text_Regular.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 500;
src: url("/assets/fonts/Golos-Text_Medium.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 600;
src: url("/assets/fonts/Golos-Text_DemiBold.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 700;
src: url("/assets/fonts/Golos-Text_Bold.woff2");
}
@font-face {
font-family: "Golos Sans";
font-weight: 800;
src: url("../fonts/Golos-Text_Black.woff2");
}
@theme {
--font-sans: 'Golos Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
/*hover:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 bg-transparent dark:bg-white/5*/
*::-webkit-scrollbar {
@apply w-2;
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply bg-white/10 dark:bg-white/10 rounded-lg;
}
*::-webkit-scrollbar-thumb:hover {
@apply bg-white/10 dark:bg-white/10 rounded-lg;
}

View File

@@ -0,0 +1,38 @@
<script setup>
import { ref, provide, onUnmounted } from 'vue'
const props = defineProps({
// ID элемента, который открыт по умолчанию
opened: {
type: [String, Number],
default: null
}
})
const activeItem = ref(props.opened)
const registeredItems = ref(new Set())
const accordionManager = {
activeItem,
open: (id) => {
activeItem.value = id
},
close: () => {
activeItem.value = null
},
registerItem: (id) => {
registeredItems.value.add(id)
},
unregisterItem: (id) => {
registeredItems.value.delete(id)
}
};
provide('accordionManager', accordionManager)
</script>
<template>
<div class="space-y-4">
<slot />
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup>
import {computed, ref} from "vue";
const props = defineProps({
variant: {
type: String,
validator(value, props) {
return ['success', 'warning', 'danger', 'info'].includes(value)
}
}
})
const baseClasses = [
'inline-flex',
'items-center',
'gap-x-1.5',
'rounded-md',
'px-1.5',
'py-0.5',
'text-sm/5',
'font-medium',
'sm:text-xs/5',
'forced-colors:outline',
]
const successColorClasses = [
'bg-lime-400/20',
'text-lime-700',
'group-data-hover:bg-lime-400/30',
'dark:bg-lime-400/10',
'dark:text-lime-300',
'dark:group-data-hover:bg-lime-400/15'
]
const warningColorClasses = [
'bg-amber-400/20',
'text-amber-700',
'group-data-hover:bg-amber-400/25',
'dark:bg-amber-400/10',
'dark:text-amber-400',
'dark:group-data-hover:bg-amber-400/20'
]
const dangerColorClasses = [
'bg-rose-400/20',
'text-rose-700',
'group-data-hover:bg-rose-400/25',
'dark:bg-rose-400/10',
'dark:text-rose-400',
'dark:group-data-hover:bg-rose-400/20'
]
const infoColorClasses = [
'bg-sky-400/20',
'text-sky-700',
'group-data-hover:bg-sky-400/25',
'dark:bg-sky-400/10',
'dark:text-sky-400',
'dark:group-data-hover:bg-sky-400/20'
]
const primaryColorClasses = [
'bg-orange-400/20',
'text-orange-700',
'group-data-hover:bg-orange-400/25',
'dark:bg-orange-400/10',
'dark:text-orange-400',
'dark:group-data-hover:bg-orange-400/20'
]
const colorClasses = {
success: successColorClasses,
warning: warningColorClasses,
danger: dangerColorClasses,
info: infoColorClasses,
primary: primaryColorClasses
}
const computedClasses = computed(() => {
return [
...baseClasses,
...(colorClasses[props.variant] || [])
]
})
</script>
<template>
<div :data-variant="variant" :class="computedClasses">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,212 @@
<script setup>
import {computed, ref, watch} from "vue";
const emits = defineEmits([
'click'
])
const props = defineProps({
tag: {
type: [String, Object],
default: 'button'
},
href: {
type: String,
default: null
},
icon: {
type: Boolean,
default: false
},
block: {
type: Boolean,
default: false
},
variant: {
type: String,
default: 'default'
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
textAlign: {
type: String,
default: 'left'
},
iconLeft: {
type: Boolean,
default: false
},
iconRight: {
type: Boolean,
default: false
},
maxWidth: {
type: [String, Number],
default: 'none'
},
})
const baseClasses = [
'group', 'cursor-pointer', 'relative', 'block', 'appearance-none', 'rounded-lg', 'text-left', 'text-base/6',
'sm:text-sm/6', 'border', 'active:scale-[.99]',
'transition-all'
]
const paddingClasses = [
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(1.5)-1px)]',
'px-[calc(--spacing(3.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
]
const paddingClassesIcon = [
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(2.5)-1px)]',
'px-[calc(--spacing(2.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
]
const variants = {
default: [
'text-zinc-950', 'placeholder:text-zinc-500', 'dark:text-white', 'border-zinc-950/10',
'hover:border-zinc-950/20', 'dark:border-white/10', 'dark:hover:border-white/20', 'bg-transparent',
'dark:bg-white/5'
],
warning: [
'text-white', 'placeholder:text-amber-500', 'border-amber-900',
'hover:border-amber-400', 'dark:border-amber-700', 'dark:hover:border-amber-600', 'bg-transparent',
'dark:bg-amber-800'
],
danger: [
'placeholder:text-rose-500', 'border-rose-900',
'hover:border-rose-400', 'dark:border-rose-600', 'dark:hover:border-rose-400', 'bg-transparent',
'dark:bg-rose-800'
],
ghost: [
'bg-transparent', 'border-transparent', 'hover:border-zinc-950/20', 'dark:hover:border-white/20',
]
}
const textContainerClasses = computed(() => {
let base = ['flex', 'flex-row', 'gap-x-2', 'relative']
if (props.textAlign) {
const align = {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end',
}
const textAlign = align[props.textAlign]
base = base.concat([textAlign])
}
return base
})
const classes = computed(() => {
let base = [...baseClasses]
if (props.icon) {
base = base.concat(paddingClassesIcon)
} else {
base = base.concat(paddingClasses)
}
if (props.block) {
base = base.concat(['w-full'])
}
if (props.variant) {
base = base.concat(variants[props.variant])
}
return base
})
const textClasses = computed(() => {
let base = ['min-w-0', 'w-full']
// Автоматически рассчитываем максимальную ширину если есть иконки
if (props.maxWidth === 'none') {
if (props.iconLeft && props.iconRight) {
base = base.concat(['max-w-[calc(100%-8rem)]']) // минус 2 иконки
} else if (props.iconLeft || props.iconRight) {
base = base.concat(['max-w-[calc(100%-1.5rem)]']) // минус 1 иконка
}
} else if (props.maxWidth) {
base = base.concat([`max-w-[${props.maxWidth}]`])
}
base = base.concat(['truncate'])
return base
})
const handleClick = (event) => {
if (props.loading || props.disabled) {
event.preventDefault()
return
}
emits('click', event)
}
</script>
<template>
<component :is="tag"
:href="href"
@click="handleClick"
:class="classes"
:disabled="loading || disabled"
v-bind="$attrs">
<div :class="textContainerClasses">
<!-- Спиннер загрузки -->
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<div class="flex items-center gap-x-2">
<!-- Анимированный спиннер -->
<div class="flex space-x-1">
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
</div>
</div>
<!-- Основной контент кнопки (скрывается при loading) -->
<div
:class="[
'flex flex-row gap-x-2 items-center transition-opacity duration-200 w-full',
loading ? 'opacity-0' : 'opacity-100'
]"
>
<div
v-if="($slots.iconLeft || (iconLeft && $slots.icon))"
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
>
<slot name="iconLeft">
<slot name="icon" />
</slot>
</div>
<div :class="textClasses">
<slot />
</div>
<div
v-if="$slots.iconRight || (iconRight && $slots.icon)"
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
>
<slot name="iconRight">
<slot name="icon" />
</slot>
</div>
</div>
</div>
</component>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,433 @@
<script setup>
import { computed, ref, watch } from 'vue'
import {
format,
parse,
startOfMonth,
endOfMonth,
eachDayOfInterval,
startOfWeek,
endOfWeek,
isSameDay,
isSameMonth,
isToday,
addMonths,
subMonths,
isValid
} from 'date-fns'
import { ru } from 'date-fns/locale'
const props = defineProps({
modelValue: {
type: [Date, String, null],
default: null
},
placeholder: {
type: String,
default: 'Выберите дату'
},
disabled: {
type: Boolean,
default: false
},
variant: {
type: String,
default: 'default'
},
format: { // Пользователь вводит любой формат date-fns
type: String,
default: 'dd.MM.yyyy'
},
returnFormatted: {
type: Boolean,
default: true
},
locale: {
type: Object,
default: () => ru
},
block: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const baseClasses = [
'group', 'cursor-pointer', 'relative', 'block', 'appearance-none', 'rounded-lg', 'text-left', 'text-base/6',
'sm:text-sm/6', 'border', 'transition-all', 'disabled:cursor-not-allowed', 'disabled:opacity-50'
]
const paddingClasses = [
'py-[calc(--spacing(2.5)-1px)]', 'sm:py-[calc(--spacing(1.5)-1px)]',
'px-[calc(--spacing(3.5)-1px)]', 'sm:px-[calc(--spacing(2.5)-1px)]',
]
const variants = {
default: [
'text-zinc-950', 'placeholder:text-zinc-500', 'dark:text-white', 'border-zinc-950/10',
'hover:border-zinc-950/20', 'dark:border-white/10', 'dark:hover:border-white/20', 'bg-transparent',
'dark:bg-white/5'
],
warning: [
'text-white', 'placeholder:text-amber-500', 'border-amber-900',
'hover:border-amber-400', 'dark:border-amber-700', 'dark:hover:border-amber-600', 'bg-transparent',
'dark:bg-amber-800'
],
danger: [
'placeholder:text-rose-500', 'border-rose-900',
'hover:border-rose-400', 'dark:border-rose-600', 'dark:hover:border-rose-400', 'bg-transparent',
'dark:bg-rose-800'
],
ghost: [
'bg-transparent', 'border-transparent', 'hover:border-zinc-950/20', 'dark:hover:border-white/20',
'hover:dark:bg-white/5'
]
}
const classes = computed(() => {
let base = [...baseClasses, ...paddingClasses]
if (props.variant) {
base = base.concat(variants[props.variant])
}
if (props.block) base.push('w-full')
return base
})
// Парсинг входящего значения
const parseInputValue = (value) => {
if (!value) return null
if (value instanceof Date) {
return isValid(value) ? value : null
}
if (typeof value === 'string') {
try {
// Пробуем распарсить с текущим форматом
const parsed = parse(value, props.format, new Date(), { locale: props.locale })
if (isValid(parsed)) return parsed
// Пробуем стандартные форматы как fallback
const standardFormats = [
'dd.MM.yyyy',
'yyyy-MM-dd',
'd MMMM yyyy',
'd MMM yyyy',
'dd/MM/yyyy',
'MM/dd/yyyy'
]
for (const fmt of standardFormats) {
const parsed = parse(value, fmt, new Date(), { locale: props.locale })
if (isValid(parsed)) return parsed
}
// Последняя попытка - стандартный парсинг
const parsedDate = new Date(value)
if (isValid(parsedDate)) return parsedDate
} catch (e) {
console.warn('Не удалось распарсить дату:', value)
}
}
return null
}
// Состояния
const isOpen = ref(false)
const selectedDate = ref(props.modelValue ? parseInputValue(props.modelValue) : null)
const currentMonth = ref(selectedDate.value || new Date())
const today = new Date()
// Дни недели
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
// Функция для экранирования текста в формате
const escapeFormat = (formatString) => {
// Если формат уже содержит экранированный текст, оставляем как есть
if (formatString.includes("'")) {
return formatString
}
// Ищем текстовые части и экранируем их
const tokens = formatString.split(/(\s+)/)
let result = ''
let inText = false
for (const token of tokens) {
if (token.trim() === '') {
result += token
continue
}
// Проверяем, является ли токен текстом (не паттерном date-fns)
const isPattern = /^(dd?|MM?M?M?|yy?yy?|EEE?E?|QQ?Q?|ww?|HH?|hh?|mm?|ss?|SSS?|aaaa?|xxxx?|XXXX?|ZZZ?)$/.test(token)
if (!isPattern && !inText) {
result += `'${token}`
inText = true
} else if (!isPattern && inText) {
result += ` ${token}`
} else if (isPattern && inText) {
result += `' ${token}`
inText = false
} else {
result += token
}
}
// Закрываем последнюю текстовую часть
if (inText) {
result += "'"
}
return result
}
// Форматирование даты с помощью date-fns
const formatDate = (date) => {
if (!date || !isValid(date)) return ''
try {
// Пробуем форматировать как есть
return format(date, props.format, { locale: props.locale })
} catch (error) {
console.warn('Ошибка форматирования даты:', error)
// Fallback: пробуем заменить проблемные символы
try {
// Экранируем весь текст, который не является паттерном date-fns
const safeFormat = props.format
.replace(/([^dMyYwWDEHhmsSaZXx']+|'.*?')/g, "'$1'")
.replace(/''/g, "'")
return format(date, safeFormat, { locale: props.locale })
} catch (e) {
// Ultimate fallback
return format(date, 'dd.MM.yyyy', { locale: props.locale })
}
}
}
// Получить дни для календаря
const calendarDays = computed(() => {
const start = startOfWeek(startOfMonth(currentMonth.value), { weekStartsOn: 1 })
const end = endOfWeek(endOfMonth(currentMonth.value), { weekStartsOn: 1 })
return eachDayOfInterval({ start, end })
})
// Текущий месяц и год
const currentMonthYear = computed(() => {
return format(currentMonth.value, 'LLLL yyyy', { locale: props.locale })
})
// Форматированная дата для отображения
const formattedDate = computed(() => {
if (!selectedDate.value) return props.placeholder
return formatDate(selectedDate.value)
})
// Методы
const selectDate = (date) => {
if (!date || props.disabled) return
selectedDate.value = date
if (props.returnFormatted) {
const formattedValue = formatDate(date)
emit('update:modelValue', formattedValue)
} else {
emit('update:modelValue', date)
}
isOpen.value = false
}
const prevMonth = () => {
currentMonth.value = subMonths(currentMonth.value, 1)
}
const nextMonth = () => {
currentMonth.value = addMonths(currentMonth.value, 1)
}
// Вспомогательные методы для проверок
const isDateToday = (date) => isToday(date)
const isDateSelected = (date) => selectedDate.value && isSameDay(date, selectedDate.value)
const isDateCurrentMonth = (date) => isSameMonth(date, currentMonth.value)
// Методы для внешнего использования
const getFormattedDate = (date = null, customFormat = null) => {
const targetDate = date || selectedDate.value
if (!targetDate || !isValid(targetDate)) return null
const formatToUse = customFormat || props.format
try {
return format(targetDate, formatToUse, { locale: props.locale })
} catch (error) {
console.warn('Ошибка форматирования:', error)
return format(targetDate, 'dd.MM.yyyy', { locale: props.locale })
}
}
const getDateObject = () => selectedDate.value
defineExpose({
getFormattedDate,
getDateObject
})
// Закрытие по клику вне компонента
const closeOnClickOutside = (event) => {
if (!event.target.closest('.calendar-container')) {
isOpen.value = false
}
}
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
document.addEventListener('click', closeOnClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', closeOnClickOutside)
})
// Следим за изменением modelValue
watch(() => props.modelValue, (newValue) => {
const parsed = parseInputValue(newValue)
if (parsed && (!selectedDate.value || !isSameDay(parsed, selectedDate.value))) {
selectedDate.value = parsed
if (parsed) {
currentMonth.value = startOfMonth(parsed)
}
}
})
// Следим за изменением формата
watch(() => props.format, (newFormat) => {
// Если дата уже выбрана, переформатируем её
if (selectedDate.value && props.returnFormatted) {
const formattedValue = formatDate(selectedDate.value)
emit('update:modelValue', formattedValue)
}
})
</script>
<template>
<div class="calendar-container relative">
<!-- Триггер -->
<button
:class="classes"
@click="isOpen = !isOpen"
:disabled="disabled"
type="button"
>
<div class="flex flex-row gap-x-2 items-center justify-between w-full">
<span :class="!selectedDate ? 'text-zinc-500 dark:text-zinc-400' : ''">
{{ formattedDate }}
</span>
<svg
class="size-4 stroke-zinc-500 group-disabled:stroke-zinc-600 dark:stroke-zinc-400 transition-transform duration-200 flex-shrink-0"
:class="{ 'rotate-180': isOpen }"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
<!-- Выпадающий календарь -->
<div
v-if="isOpen"
class="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-zinc-900 border border-zinc-950/10 dark:border-white/10 rounded-lg shadow-lg p-4 min-w-64"
>
<!-- Заголовок с навигацией -->
<div class="flex items-center justify-between mb-4">
<button
@click="prevMonth"
class="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<svg class="size-4 stroke-current" fill="none" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-sm font-medium text-zinc-900 dark:text-white">
{{ currentMonthYear }}
</span>
<button
@click="nextMonth"
class="p-1 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<svg class="size-4 stroke-current" fill="none" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- Дни недели -->
<div class="grid grid-cols-7 gap-1 mb-2">
<div
v-for="day in weekDays"
:key="day"
class="text-xs text-center text-zinc-500 dark:text-zinc-400 py-1"
>
{{ day }}
</div>
</div>
<!-- Дни месяца -->
<div class="grid grid-cols-7 gap-1">
<button
v-for="date in calendarDays"
:key="date.getTime()"
@click="selectDate(date)"
:disabled="disabled"
class="aspect-square p-1 text-sm rounded transition-all duration-200"
:class="[
!isDateCurrentMonth(date) ? 'text-zinc-400 dark:text-zinc-600' : '',
isDateSelected(date)
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
: isDateToday(date)
? 'border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
]"
>
{{ format(date, 'd', { locale }) }}
</button>
</div>
<!-- Быстрый выбор -->
<div class="flex justify-between mt-4 pt-3 border-t border-zinc-200 dark:border-zinc-700">
<button
@click="selectDate(today)"
class="text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
:disabled="disabled"
>
Сегодня
</button>
<button
@click="selectDate(null)"
class="text-xs text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors"
:disabled="disabled"
>
Очистить
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
import CardHeader from "./CardHeader.vue";
import CardBack from "./CardBack.vue";
import {computed} from "vue";
const props = defineProps({
header: String,
contentScroll: {
type: Boolean,
default: true
},
contentRelative: {
type: Boolean,
default: true
},
mergeContentClass: {
type: String,
default: ''
}
})
const contentClass = computed(() => {
const classes = ['p-3 h-full']
props.contentRelative ? classes.push('relative') : delete classes.find(cls => cls === 'relative')
if (props.contentScroll) {
classes.push('overflow-y-auto')
delete classes.find(cls => cls === 'overflow-y-clip')
} else {
classes.push('overflow-y-clip')
delete classes.find(cls => cls === 'overflow-y-auto')
}
if (props.mergeContentClass) {
const mergeClasses = props.mergeContentClass.split(' ')
classes.push(...mergeClasses)
}
return classes
})
</script>
<template>
<div class="h-full flex flex-col justify-between overflow-clip rounded-lg bg-white lg:shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
<div class="p-3">
<CardHeader>
<slot v-if="$slots.header" name="header" />
<span v-else>
{{ header }}
</span>
</CardHeader>
</div>
<div :class="contentClass">
<slot />
</div>
<div v-if="$slots.footer" class="p-3">
<slot name="footer" />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import Button from "../Button/Button.vue"
const emits = defineEmits([
'click'
])
const props = defineProps({
tag: {
type: [String, Object],
default: 'button'
}
})
</script>
<template>
<Button :tag="tag" v-bind:href="$attrs.href" @click="emits('click')" icon-left>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M5 12l6 6"></path>
<path d="M5 12l6-6"></path>
</g>
</svg>
</template>
Вернуться назад
</Button>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div class="text-sm lg:rounded-lg lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-800 dark:lg:ring-white/10 px-3.5 py-2">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,5 @@
<template>
<div class="text-sm lg:rounded-lg bg-zinc-100 lg:ring-1 lg:ring-zinc-950/10 dark:lg:bg-zinc-800 dark:lg:ring-white/10 px-3.5 py-2">
<slot />
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup>
import {ref, computed, inject, onUnmounted} from "vue";
const props = defineProps({
header: String,
defaultOpen: {
type: Boolean,
default: false
},
contentScroll: {
type: Boolean,
default: true
},
contentRelative: {
type: Boolean,
default: true
},
mergeContentClass: {
type: String,
default: ''
},
id: {
type: [String, Number],
default: null
}
});
// Инжектим управление аккордеоном из родителя
const accordionManager = inject('accordionManager', null);
const localOpen = ref(props.defaultOpen)
const isOpen = computed(() => {
if (accordionManager) {
return accordionManager.activeItem.value === props.id;
}
return localOpen.value;
});
const toggle = () => {
if (accordionManager) {
if (isOpen.value) {
accordionManager.close();
} else {
accordionManager.open(props.id);
}
} else {
localOpen.value = !localOpen.value
}
};
// Регистрируем элемент в аккордеоне при монтировании
if (accordionManager && props.id) {
accordionManager.registerItem(props.id);
onUnmounted(() => {
accordionManager.unregisterItem(props.id);
});
}
const contentClass = computed(() => {
const classes = ['h-full transition-all duration-300 ease-in-out'];
if (isOpen.value) {
classes.push('opacity-100 max-h-[1000px]'); // max-h достаточно большой для контента
} else {
classes.push('opacity-0 max-h-0');
}
props.contentRelative ? classes.push('relative') : null;
if (props.contentScroll && isOpen.value) {
classes.push('overflow-y-auto');
} else {
classes.push('overflow-y-clip');
}
if (props.mergeContentClass) {
const mergeClasses = props.mergeContentClass.split(' ');
classes.push(...mergeClasses);
}
return classes;
});
const containerClass = computed(() => {
return [
'h-full flex flex-col justify-between overflow-clip lg:rounded-lg lg:bg-white lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10',
isOpen.value ? 'min-h-[100px]' : ''
];
});
</script>
<template>
<div :class="containerClass" :data-br-id="props.id">
<div class="p-1.5 px-2 pr-2">
<div class="flex items-center justify-between">
<div class="inline-flex gap-x-2 items-center">
<slot v-if="$slots.icon" name="icon" />
<div class="flex-1 cursor-pointer" @click="toggle">
<slot v-if="$slots.header" name="header" />
<span v-else class="block text-sm font-medium">
{{ header }}
</span>
</div>
</div>
<div class="inline-flex gap-x-2">
<slot v-if="$slots['header-extra']" name="header-extra" />
<div class="flex items-center justify-center w-6 h-6 transition-transform duration-300 cursor-pointer"
@click="toggle"
:class="{ 'rotate-180': isOpen }">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
</div>
</div>
</div>
<div :class="contentClass">
<div v-if="isOpen" class="p-3 pt-0">
<slot />
</div>
</div>
<div v-if="$slots.footer && isOpen" class="p-3">
<slot name="footer" />
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
cover: {
type: Boolean,
default: false
}
})
const defaultClasses = [
'w-[21cm] h-[29.7cm] bg-white text-black rounded-sm outline-none'
]
const defaultCoverClasses = [
'p-[2.5cm]'
]
const coverClasses = [
'pt-[2cm] pl-[3cm] pb-[2cm] pr-[1.5cm]'
]
const classes = computed(() => {
let base = [...defaultClasses]
if (props.cover)
base = base.concat(coverClasses)
else
base = base.concat(defaultCoverClasses)
return base
})
</script>
<template>
<div :class="classes">
<div class="min-h-[calc(29.7cm-5cm)] relative">
<slot />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,61 @@
<script setup>
import {computed, onMounted} from "vue"
import DocumentWarning from "./DocumentWarning.vue"
import DocumentHtml from "./DocumentHtml.vue"
import DocumentHeading from "./DocumentHeading.vue"
import DocumentParagraph from "./DocumentParagraph.vue";
import DocumentTextRun from "./DocumentTextRun.vue";
import DocumentLineBreak from "./DocumentLineBreak.vue";
import DocumentTable from "./DocumentTable.vue";
const props = defineProps({
element: Object
})
const emit = defineEmits(['variable-click', 'is-mounted'])
const componentMap = {
'heading': DocumentHeading,
'html': DocumentHtml,
// 'warning': DocumentWarning,
'paragraph': DocumentParagraph,
'text_run': DocumentTextRun,
'line_break': DocumentLineBreak,
'table': DocumentTable
}
const componentType = computed(() => {
return componentMap[props.element.type] || 'div'
})
const elementClass = computed(() => {
return `element-${props.element.type}`
})
const compiledContent = computed(() => {
return props.element.compiledContent || props.element.content
})
const handleVariableClick = (variableName) => {
emit('variable-click', variableName)
}
onMounted(() => {
emit('is-mounted', true)
})
</script>
<template>
<div class="document-element" :class="elementClass">
<component
:is="componentType"
:content="compiledContent"
:element="element"
@variable-click="handleVariableClick"
/>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,54 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
content: String,
element: Object
})
const styles = computed(() => {
const styles = {}
const style = props.element.style || {}
// Выравнивание
if (style.align) {
styles.textAlign = style.align
}
// Междустрочный интервал
if (style.lineHeight) {
styles.lineHeight = style.lineHeight
}
// Отступы
if (style.spaceBefore) {
styles.marginTop = `${style.spaceBefore}pt`
}
if (style.spaceAfter) {
styles.marginBottom = `${style.spaceAfter}pt`
}
if (style.indent) {
if (style.indent.left) {
styles.marginLeft = `${style.indent.left}pt`
}
if (style.indent.right) {
styles.marginRight = `${style.indent.right}pt`
}
if (style.indent.firstLine) {
styles.textIndent = `${style.indent.firstLine}pt`
}
}
return styles
})
</script>
<template>
<h2 :data-element-id="element.id" :style="styles" class="font-bold" v-html="content"></h2>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,35 @@
<script setup>
defineProps({
content: String
})
</script>
<template>
<div class="html-content mb-4" v-html="content"></div>
</template>
<style scoped>
.html-content {
text-align: justify;
}
.html-content ::v-deep(.placeholder) {
background-color: #fffacd;
border-bottom: 1px dashed #ccc;
cursor: pointer;
padding: 0 2px;
border-radius: 2px;
}
.html-content ::v-deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
.html-content ::v-deep(table, th, td) {
border: 1px solid black;
padding: 8px;
text-align: left;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup>
const props = defineProps({
element: {
type: Object,
default: {}
}
})
</script>
<template>
<br :data-element-id="element.id" class="block content-[\'\'] m-0 p-0 h-0" />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,139 @@
<script setup>
import { computed } from 'vue';
import DocumentText from './DocumentText.vue';
// import DocumentVariable from './DocumentVariable.vue';
import DocumentTextRun from './DocumentTextRun.vue';
import DocumentLineBreak from './DocumentLineBreak.vue';
const props = defineProps({
element: Object,
formData: Object
});
defineEmits(['variable-click']);
const elementComponents = {
text: DocumentText,
// variable: DocumentVariable,
text_run: DocumentTextRun,
line_break: DocumentLineBreak
};
const paragraphStyles = computed(() => {
const styles = {};
const paragraphStyle = props.element.style || {};
// Выравнивание
if (paragraphStyle.align) {
styles.textAlign = paragraphStyle.align;
}
// Междустрочный интервал
if (paragraphStyle.lineHeight) {
styles.lineHeight = paragraphStyle.lineHeight;
}
// Отступы
if (paragraphStyle.spaceBefore) {
styles.marginTop = `${paragraphStyle.spaceBefore}pt`;
}
if (paragraphStyle.spaceAfter) {
styles.marginBottom = `${paragraphStyle.spaceAfter}pt`;
}
if (paragraphStyle.indent) {
if (paragraphStyle.indent.left) {
styles.marginLeft = `${paragraphStyle.indent.left}pt`;
}
if (paragraphStyle.indent.right) {
styles.marginRight = `${paragraphStyle.indent.right}pt`;
}
if (paragraphStyle.indent.firstLine) {
styles.textIndent = `${paragraphStyle.indent.firstLine}pt`;
}
}
return styles;
});
const paragraphClasses = computed(() => {
const classes = ['paragraph-element'];
if (props.element.style?.align) {
classes.push(`align-${props.element.style.align}`);
}
return classes;
});
const getComponentForElement = (element) => {
return elementComponents[element.type] || 'span';
};
</script>
<template>
<p
class="document-paragraph"
:style="paragraphStyles"
:class="paragraphClasses"
>
<template v-if="element.elements" v-for="element in element.elements" :key="element.id">
<component
:is="getComponentForElement(element)"
:element="element"
:form-data="formData"
:is-inline="true"
@variable-click="$emit('variable-click', $event)"
/>
</template>
<template v-else>
{{ element.content }}
</template>
</p>
</template>
<style scoped>
.document-paragraph {
margin: 0.5em 0;
text-align: left;
}
.document-paragraph.align-center {
text-align: center;
}
.document-paragraph.align-right {
text-align: right;
}
.document-paragraph.align-justify {
text-align: justify;
}
.document-paragraph.align-distribute {
text-align: justify;
text-justify: distribute;
}
/* Наследование стилей для вложенных элементов */
.document-paragraph ::v-deep(.text-element) {
display: inline;
}
.document-paragraph ::v-deep(.variable-element) {
display: inline;
}
.document-paragraph ::v-deep(.text-run) {
display: inline;
}
/* Стили для печати */
@media print {
.document-paragraph {
text-align: justify;
margin: 0.75em 0;
}
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup>
import DocumentElement from "./DocumentElement.vue"
import A4 from "./A4.vue";
import {computed, getCurrentInstance, nextTick, onMounted, ref, watch, watchEffect} from "vue";
import {useDynamicA4Layout} from "../../Composables/useDynamicA4Layout.js";
import {waitForAllComponentsMounted} from "../../Utils/heightCalculator.js";
const props = defineProps({
structure: Array,
editable: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['variable-click'])
const {calculateDynamicLayout, componentHeights, a4Pages} = useDynamicA4Layout()
const allElements = computed(() => props.structure.elements)
const componentRefs = ref(new Map())
const handleVariableClick = (variableName) => {
emit('variable-click', variableName)
}
const elementRefs = ref(new Map())
const addToRefs = (el, id) => {
if (el)
elementRefs.value.set(id, el.$el)
}
const hasAllMounted = computed(() => {
// Array.from(elementRefs.value.values())
// .every(ref => ref.value?.$.vnode.el?.isConnected)
})
// watch(() => hasAllMounted, async (newSize, oldSize) => {
// const expectedCount = props.structure?.elements?.length || 0
//
// if (newSize === expectedCount && newSize > 0 && newSize !== oldSize) {
// // Даём время на полное монтирование
// await nextTick()
// await nextTick() // Двойной nextTick для надёжности
//
// console.log(hasAllMounted.value)
//
// // try {
// // await waitForAllComponentsMounted(getCurrentInstance())
// // console.log('Все компоненты смонтированы!')
// // await calculateDynamicLayout(props.structure.elements, () => elementRefs.value)
// // } catch (error) {
// // console.error('Ошибка при ожидании монтирования:', error)
// // }
// }
// })
onMounted(() => {
// console.log(Array.from(elementRefs.value.values())[0].)
})
watch(() => elementRefs.value.size, async () => {
const expectedCount = props.structure?.elements?.length || 0
const currentSize = elementRefs.value.size
if (currentSize > 0 && currentSize === expectedCount) {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 5000))
// console.log('Все компоненты смонтированы!')
await calculateDynamicLayout(props.structure.elements, () => elementRefs.value)
}
}, {
flush: 'post'
})
</script>
<template>
<A4 class="absolute -left-[9999px] -top-[9999px] section collapse document-content overflow-hidden">
<DocumentElement
v-for="element in allElements"
:ref="el => addToRefs(el, element.id)"
:element="element"
/>
</A4>
<A4 v-for="paginatedElement in a4Pages" cover class="section document-content my-2 overflow-hidden">
<template v-for="item in paginatedElement.items" :key="item.id">
<DocumentElement
:contenteditable="editable"
:class="editable ? 'overflow-hidden outline-none' : ''"
:element="item"
@variable-click="handleVariableClick"
/>
</template>
</A4>
</template>
<style scoped>
.document-content {
line-height: 1.2;
font-family: 'Times New Roman', serif;
font-size: 14px;
}
.section {
page-break-inside: avoid;
}
</style>

View File

@@ -0,0 +1,40 @@
<script setup>
import {computed, ref} from "vue";
import DocumentElement from "./DocumentElement.vue";
import DocumentTableRow from "./DocumentTableRow.vue";
const props = defineProps({
content: String,
element: Object,
})
const rows = computed(() => props.element.rows || [])
const cols = computed(() => props.element.cols || 0)
</script>
<template>
<div>
<table :data-element-id="element.id" class="table-element">
<tbody>
<DocumentTableRow @is-mounted="" :rows="rows" />
<!-- <tr-->
<!-- v-for="(row, rowIndex) in rows"-->
<!-- :key="`row-${rowIndex}`"-->
<!-- >-->
<!-- <td-->
<!-- v-for="(cell, colIndex) in row.cells"-->
<!-- :key="`td-${rowIndex}-${colIndex}`"-->
<!-- :style="`width: ${cell.width}px`"-->
<!-- >-->
<!-- <DocumentElement v-for="element in cell.elements" :element="element" :key="element.id" />-->
<!-- </td>-->
<!-- </tr>-->
</tbody>
</table>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
const props = defineProps({
element: Object
})
</script>
<template>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,29 @@
<script setup>
import DocumentElement from "./DocumentElement.vue";
import {onMounted} from "vue";
const props = defineProps({
rows: Array
})
const emits = defineEmits(['is-mounted'])
onMounted(() => {
emits('is-mounted', true)
})
</script>
<template>
<tr
v-for="(row, rowIndex) in rows"
:key="`row-${rowIndex}`"
>
<td v-for="cell in row.cells" :style="`width: ${cell.width}px`">
<DocumentElement v-for="element in cell.elements" :element="element" />
</td>
</tr>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,93 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
parentElement: Object,
element: Object,
formData: Object,
isInline: {
type: Boolean,
default: false
}
});
const displayText = computed(() => {
return props.element.content || '';
});
const textStyles = computed(() => {
const styles = {};
const formatting = props.element.formatting || {};
if (formatting.fontSize) {
styles.fontSize = formatting.fontSize;
}
if (formatting.fontFamily) {
styles.fontFamily = formatting.fontFamily;
}
if (formatting.fontColor) {
styles.color = formatting.fontColor;
}
return styles;
});
const textClasses = computed(() => {
const classes = [];
const formatting = props.element.formatting || {};
if (formatting.bold) {
classes.push('text-bold');
}
if (formatting.italic) {
classes.push('text-italic');
}
if (formatting.underline !== 'none') {
classes.push('text-underline');
}
return classes;
});
</script>
<template>
<span
:data-element-id="element.id"
class="text-element"
:style="textStyles"
:class="textClasses"
>
{{ displayText }}
</span>
</template>
<style scoped>
.text-element {
display: inline;
white-space: pre-wrap;
}
.text-bold {
font-weight: bold;
}
.text-italic {
font-style: italic;
}
.text-underline {
text-decoration: underline;
}
/* Стили для печати */
@media print {
.text-element {
color: black !important;
font-family: 'Times New Roman', serif !important;
}
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
import {computed, ref} from 'vue';
import DocumentText from './DocumentText.vue';
// import DocumentVariable from './DocumentVariable.vue';
import DocumentLineBreak from './DocumentLineBreak.vue';
const props = defineProps({
element: Object,
formData: Object
});
defineEmits(['variable-click']);
const elementComponents = {
text: DocumentText,
// variable: DocumentVariable,
line_break: DocumentLineBreak
}
const textRunRef = ref()
const combinedStyles = computed(() => {
const styles = {}
const formatting = props.element.formatting || {}
const paragraphStyle = props.element.style || {}
// Стили шрифта
if (formatting.fontSize) {
styles.fontSize = formatting.fontSize;
}
if (formatting.fontFamily) {
styles.fontFamily = formatting.fontFamily;
}
if (formatting.fontColor) {
styles.color = formatting.fontColor;
}
if (formatting.backgroundColor) {
styles.backgroundColor = formatting.backgroundColor;
}
// Стили параграфа (если есть)
if (paragraphStyle.align) {
styles.textAlign = paragraphStyle.align;
}
if (paragraphStyle.lineHeight) {
styles.lineHeight = paragraphStyle.lineHeight;
}
if (paragraphStyle.indent) {
// console.log(paragraphStyle.indent)
if (paragraphStyle.indent.firstLine)
styles['--first-line-indent'] = `${paragraphStyle.indent.firstLine}px`
}
return styles;
});
const textRunClasses = computed(() => {
// const classes = []
// const style = props.element.style
// if (style && style.indent) {
// if (style.indent.firstLine)
// classes.push(`first:pl-[${style.indent.firstLine}px]`)
// }
//
// return classes
// const classes = ['text-run-element']
//
// const formatting = props.element.formatting || {}
// if (formatting.bold) classes.push('text-bold')
// if (formatting.italic) classes.push('text-italic')
// if (formatting.underline !== 'none') classes.push('text-underline')
// if (formatting.strikethrough) classes.push('text-strikethrough')
//
// return classes;
});
const getComponentForElement = (element) => {
return elementComponents[element.type] || 'span';
};
</script>
<template>
<div ref="textRunRef" :data-element-id="element.id" :style="combinedStyles" >
<template v-for="children in element.elements" :key="children.id" >
<DocumentText
class="first:pl-[var(--first-line-indent)]"
:element="children"
:form-data="formData"
@variable-click="$emit('variable-click', $event)"
/>
</template>
</div>
</template>
<style scoped>
.text-run {
display: inline;
line-height: inherit;
}
.text-run-element {
display: inline;
}
.text-bold {
font-weight: bold;
}
.text-italic {
font-style: italic;
}
.text-underline {
text-decoration: underline;
}
.text-strikethrough {
text-decoration: line-through;
}
/* Наследование стилей для вложенных элементов */
.text-run ::v-deep(.text-element) {
display: inline;
}
.text-run ::v-deep(.variable-element) {
display: inline;
}
.text-run ::v-deep(.line-break) {
display: block;
height: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup>
defineProps({
content: String
})
</script>
<template>
<div class="warning-note bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700" v-html="content"></p>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,136 @@
<script setup>
import Input from "../../Input/Input.vue";
import {computed, watch} from "vue";
const numberModel = defineModel('number')
const textModel = defineModel('text')
// Функция для преобразования числа в текст
const numberToWords = (num) => {
if (num === 0) return 'ноль рублей'
if (num < 0) return 'минус ' + numberToWords(Math.abs(num))
const units = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const teens = ['десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать']
const tens = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто']
const hundreds = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот']
const thousands = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const millions = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
const billions = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
// Функция для преобразования трехзначного числа
const convertThreeDigit = (n, isFemale = false) => {
if (n === 0) return ''
let result = ''
const hundred = Math.floor(n / 100)
const remainder = n % 100
if (hundred > 0) {
result += hundreds[hundred] + ' '
}
if (remainder >= 20) {
const ten = Math.floor(remainder / 10)
const unit = remainder % 10
result += tens[ten] + ' '
if (unit > 0) {
if (isFemale && unit <= 2) {
result += (unit === 1 ? 'одна' : 'две') + ' '
} else {
result += units[unit] + ' '
}
}
} else if (remainder >= 10) {
result += teens[remainder - 10] + ' '
} else if (remainder > 0) {
if (isFemale && remainder <= 2) {
result += (remainder === 1 ? 'одна' : 'две') + ' '
} else {
result += units[remainder] + ' '
}
}
return result.trim()
}
// Функция для получения правильного окончания
const getEnding = (num, forms) => {
const lastDigit = num % 10
const lastTwoDigits = num % 100
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
return forms[2]
}
if (lastDigit === 1) {
return forms[0]
}
if (lastDigit >= 2 && lastDigit <= 4) {
return forms[1]
}
return forms[2]
}
let result = ''
let remaining = num
// Миллиарды
const billionsPart = Math.floor(remaining / 1000000000)
if (billionsPart > 0) {
result += convertThreeDigit(billionsPart) + ' '
result += getEnding(billionsPart, ['миллиард', 'миллиарда', 'миллиардов']) + ' '
remaining %= 1000000000
}
// Миллионы
const millionsPart = Math.floor(remaining / 1000000)
if (millionsPart > 0) {
result += convertThreeDigit(millionsPart) + ' '
result += getEnding(millionsPart, ['миллион', 'миллиона', 'миллионов']) + ' '
remaining %= 1000000
}
// Тысячи
const thousandsPart = Math.floor(remaining / 1000)
if (thousandsPart > 0) {
result += convertThreeDigit(thousandsPart, true) + ' '
result += getEnding(thousandsPart, ['тысяча', 'тысячи', 'тысяч']) + ' '
remaining %= 1000
}
// Сотни, десятки, единицы
if (remaining > 0) {
result += convertThreeDigit(remaining) + ' '
}
// Добавляем рубли с правильным окончанием
result += getEnding(num, ['рубль', 'рубля', 'рублей'])
return result.trim().replace(/\s+/g, ' ')
}
// Вычисляемое свойство для текстового представления
const amountInWords = computed(() => {
const num = Number(numberModel.value) || 0
return numberToWords(num)
})
// Следим за изменениями и обновляем модель
watch(amountInWords, (newValue) => {
textModel.value = `${numberModel.value} руб. (${newValue})`
}, { immediate: true })
// Обработчик изменения ввода
const handleInput = (value) => {
numberModel.value = value
}
</script>
<template>
<Input v-model:value="numberModel" @update:value="handleInput" placeholder="Введите сумму" type="number" />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,753 @@
<template>
<div class="editor" ref="editor">
<!-- Page overlays (headers, footers, page numbers, ...) -->
<div v-if="overlay" class="overlays" ref="overlays">
<div v-for="(page, page_idx) in pages" class="overlay" :key="page.uuid+'-overlay'" :ref="(elt) => (pages_overlay_refs[page.uuid] = elt)"
v-html="overlay(page_idx+1, pages.length)" :style="page_style(page_idx, false)">
</div>
</div>
<!-- Document editor -->
<div class="content" ref="content" :contenteditable="editable" :style="page_style(-1)" @input="input" @keyup="e => processElement(e)">
<!-- This is a Vue "hoisted" static <div> which contains every page of the document and can be modified by the DOM -->
</div>
<!-- Items related to the document editor (widgets, ...) can be inserted here -->
</div>
</template>
<script setup>
import {computed, defineCustomElement, onBeforeUpdate, onMounted, onUnmounted, ref, watch} from 'vue';
import { move_children_forward_recursively, move_children_backwards_with_merging } from '../Utils/pageTransitionMgmt.js';
const props = defineProps({
// This contains the initial content of the document that can be synced
// It must be an Array: each array item is a new set of pages containing the
// item (string or component). You can see that as predefined page breaks.
// See the Demo.vue file for a good usage example.
// content: {
// type: Array,
// required: true
// },
// Display mode of the pages
display: {
type: String,
default: "grid" // ["grid", "horizontal", "vertical"]
},
// Sets whether document text can be modified
editable: {
type: Boolean,
default: true
},
// Overlay function returning page headers and footers in HTML
overlay: Function,
// Pages format in mm (should be an array containing [width, height])
page_format_mm: {
type: Array,
default: () => [210, 297]
},
// Page margins in CSS
page_margins: {
type: [String, Function],
default: "10mm 15mm"
},
// Display zoom. Only acts on the screen display
zoom: {
type: Number,
default: 1.0
},
// "Do not break" test function: should return true on elements you don't want to be split over multiple pages but rather be moved to the next page
do_not_break: Function
})
const emits = defineEmits(['update:content', 'update:current-style', 'update:activeElement'])
const model = defineModel()
const editor = ref()
const content = ref()
const overlays = ref()
const pages = ref([]) // contains {uuid, content_idx, prev_html, template, props, elt} for each pages of the document
const pages_overlay_refs = ref({}) // contains page overlay ref elements indexed by uuid
const pages_height = ref(0) // real measured page height in px (corresponding to page_format_mm[1])
const editor_width = ref(0) // real measured with of an empty editor <div> in px
const prevent_next_content_update_from_parent = ref(false) // workaround to avoid infinite update loop
const current_text_style = ref(false) // contains the style at caret position
const activeElement = defineModel('active-element')
const activeElements = defineModel('active-elements')
const printing_mode = ref(false) // flag set when page is rendering in printing mode
const reset_in_progress = ref(false)
const fit_in_progress = ref(false)
const _page_body = ref()
const css_media_style = computed(() => {
const style = document.createElement("style");
document.head.appendChild(style);
return style;
})
onMounted(() => {
update_editor_width();
update_css_media_style();
reset_content();
window.addEventListener("resize", update_editor_width);
window.addEventListener("click", processElement);
window.addEventListener("beforeprint", before_print);
window.addEventListener("afterprint", after_print);
})
onBeforeUpdate(() => {
pages_overlay_refs.value = []
})
onUnmounted(() => {
window.removeEventListener("resize", update_editor_width);
window.removeEventListener("click", processElement);
window.removeEventListener("beforeprint", before_print);
window.removeEventListener("afterprint", after_print);
})
// Computes a random 5-char UUID
const new_uuid = () => Math.random().toString(36).slice(-5)
// Resets all content from the content property
const reset_content = () => {
// Prevent launching this function multiple times
if(reset_in_progress.value) return;
reset_in_progress.value = true;
// If provided content is empty, initialize it first and exit
if(!model.value.length) {
reset_in_progress.value = false;
model.value = [""]
// emits("update:content", [""]);
return;
}
// Delete all pages and set one new page per content item
pages.value = model.value.map((content, content_idx) => ({
uuid: new_uuid(),
content_idx,
template: content.template,
props: content.props
}));
update_pages_elts();
// Get page height from first empty page
const first_page_elt = pages.value[0].elt;
if(!content.value.contains(first_page_elt)) content.value.appendChild(first_page_elt); // restore page in DOM in case it was removed
pages_height.value = first_page_elt.clientHeight + 1; // allow one pixel precision
// Initialize text pages
for(const page of pages.value) {
// set raw HTML content
if(!model.value[page.content_idx]) page.elt.innerHTML = "<div><br></div>"; // ensure empty pages are filled with at least <div><br></div>, otherwise editing fails on Chrome
else if(typeof model.value[page.content_idx] == "string") page.elt.innerHTML = "<div>"+model.value[page.content_idx]+"</div>";
else if(page.template) {
const componentElement = defineCustomElement(page.template);
customElements.define('component-'+page.uuid, componentElement);
page.elt.appendChild(new componentElement({ modelValue: page.props }));
}
// restore page in DOM in case it was removed
if(!content.value.contains(page.elt)) content.value.appendChild(page.elt);
}
// Spread content over several pages if it overflows
fit_content_over_pages();
// Remove the text cursor from the content, if any (its position is lost anyway)
content.value.blur();
// Clear "reset in progress" flag
reset_in_progress.value = false;
}
// Spreads the HTML content over several pages until it fits
const fit_content_over_pages = () => {
// Data variable pages_height.value must have been set before calling this function
if(!pages_height.value) return;
// Prevent launching this function multiple times
if(fit_in_progress.value) return;
fit_in_progress.value = true;
// Check pages that were deleted from the DOM (start from the end)
for(let page_idx = pages.value.length - 1; page_idx >= 0; page_idx--) {
const page = pages.value[page_idx];
// if user deleted the page from the DOM, then remove it from pages.value array
if(!page.elt || !document.body.contains(page.elt)) pages.value.splice(page_idx, 1);
}
// If all the document was wiped out, start a new empty document
if(!pages.value.length){
fit_in_progress.value = false; // clear "fit in progress" flag
model.value = [""]
// emits("update:content", [""]);
return;
}
// Save current selection (or cursor position) by inserting empty HTML elements at the start and the end of it
const selection = window.getSelection();
const start_marker = document.createElement("null");
const end_marker = document.createElement("null");
// don't insert markers in case selection fails (if we are editing in components in the shadow-root it selects the page <div> as anchorNode)
if(selection && selection.rangeCount && selection.anchorNode && !(selection.anchorNode.dataset && selection.anchorNode.dataset.isVDEPage != null)) {
const range = selection.getRangeAt(0);
range.insertNode(start_marker);
range.collapse(false);
range.insertNode(end_marker);
}
// Browse every remaining page
let prev_page_modified_flag = false;
for(let page_idx = 0; page_idx < pages.value.length; page_idx++) { // page length can grow inside this loop
const page = pages.value[page_idx];
let next_page = pages.value[page_idx + 1];
let next_page_elt = next_page ? next_page.elt : null;
// check if this page, the next page, or any previous page content has been modified by the user (don't apply to template pages)
if(!page.template && (prev_page_modified_flag || page.elt.innerHTML !== page.prev_innerHTML
|| (next_page_elt && !next_page.template && next_page_elt.innerHTML !== next_page.prev_innerHTML))){
prev_page_modified_flag = true;
// BACKWARD-PROPAGATION
// check if content doesn't overflow, and that next page exists and has the same content_idx
if(page.elt.clientHeight <= pages_height.value && next_page && next_page.content_idx === page.content_idx) {
// try to append every node from the next page until it doesn't fit
move_children_backwards_with_merging(page.elt, next_page_elt, () => page.elt.clientHeight > pages_height.value || !next_page_elt.childNodes.length);
}
// FORWARD-PROPAGATION
// check if content overflows
if(page.elt.clientHeight > pages_height.value) {
// if there is no next page for the same content, create it
if(!next_page || next_page.content_idx !== page.content_idx) {
next_page = { uuid: new_uuid(), content_idx: page.content_idx };
pages.value.splice(page_idx + 1, 0, next_page);
update_pages_elts();
next_page_elt = next_page.elt;
}
console.log(next_page_elt)
// move the content step by step to the next page, until it fits
move_children_forward_recursively(page.elt, next_page_elt, () => (page.elt.clientHeight <= pages_height.value), props.do_not_break);
}
// CLEANING
// remove next page if it is empty
if(next_page_elt && next_page.content_idx === page.content_idx && !next_page_elt.childNodes.length) {
pages.value.splice(page_idx + 1, 1);
}
}
// update pages in the DOM
update_pages_elts();
}
// Normalize pages HTML content
for(const page of pages.value) {
if(!page.template) page.elt.normalize(); // normalize HTML (merge text nodes) - don't touch template pages or it can break Vue
}
// Restore selection and remove empty elements
if(document.body.contains(start_marker)){
const range = document.createRange();
range.setStart(start_marker, 0);
if(document.body.contains(end_marker)) range.setEnd(end_marker, 0);
selection.removeAllRanges();
selection.addRange(range);
}
if(start_marker.parentElement) start_marker.parentElement.removeChild(start_marker);
if(end_marker.parentElement) end_marker.parentElement.removeChild(end_marker);
// Store pages HTML content
for(const page of pages.value) {
page.prev_innerHTML = page.elt.innerHTML; // store current pages innerHTML for next call
}
// Clear "fit in progress" flag
fit_in_progress.value = false;
}
// Input event
const input = (e) => {
if(!e) return; // check that event is set
fit_content_over_pages(); // fit content according to modifications
emit_new_content(); // emit content modification
if(e.inputType !== "insertText") processElement(); // update current style if it has changed
}
// Emit content change to parent
const emit_new_content = () => {
let removed_pages_flag = false; // flag to call reset_content if some pages were removed by the user
// process the new content
const new_content = model.value.map((item, content_idx) => {
// select pages that correspond to this content item (represented by its index in the array)
const pgs = pages.value.filter(page => (page.content_idx === content_idx));
// if there are no pages representing this content (because deleted by the user), mark item as false to remove it
if(!pgs.length) {
removed_pages_flag = true;
return false;
}
// if item is a string, concatenate each page content and set that
else if(typeof item == "string") {
return pgs.map(page => {
// remove any useless <div> surrounding the content
let elt = page.elt;
while(elt.children.length === 1 && elt.firstChild.tagName && elt.firstChild.tagName.toLowerCase() === "div" && !elt.firstChild.getAttribute("style")) {
elt = elt.firstChild;
}
return ((elt.innerHTML === "<br>" || elt.innerHTML === "<!---->") ? "" : elt.innerHTML); // treat a page containing a single <br> or an empty comment as an empty content
}).join('');
}
// if item is a component, just clone the item
else return { template: item.template, props: { ...item.props }};
}).filter(item => (item !== false)); // remove empty items
// avoid calling reset_content after the parent content is updated (infinite loop)
if(!removed_pages_flag) prevent_next_content_update_from_parent.value = true;
// send event to parent to update the synced content
model.value = new_content
// emits("update:content", new_content);
}
// Sets current_text_style with CSS style at caret position
const processElement = (e) => {
process_current_text_style()
processCurrentElement(e)
processSelectedElements(e)
}
const processSelectedElements = () => {
const selection = window.getSelection()
const elements = new Set()
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i)
// Получаем общий контейнер выделения
const commonAncestor = range.commonAncestorContainer
// Если это текстовый узел, берем его родителя
if (commonAncestor.nodeType === Node.TEXT_NODE) {
elements.add(commonAncestor.parentElement)
} else {
// Ищем все текстовые узлы в диапазоне
const treeWalker = document.createTreeWalker(
commonAncestor,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
return range.intersectsNode(node) ?
NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
}
}
)
let textNode
while (textNode = treeWalker.nextNode()) {
elements.add(textNode.parentElement)
}
}
}
const hasEditorParent = []
for (const element of elements) {
hasEditorParent.push(checkForEditorParent(element))
}
if (hasEditorParent.every(i => i === true) && elements.size > 1) {
activeElements.value = Array.from(elements)
return
}
activeElements.value = []
}
const process_current_text_style = () => {
let style = false;
const sel = window.getSelection();
if(sel.focusNode) {
const element = sel.focusNode.tagName ? sel.focusNode : sel.focusNode.parentElement;
if(element && element.isContentEditable) {
style = window.getComputedStyle(element);
// compute additional properties
style.textDecorationStack = []; // array of text-decoration strings from parent elements
style.headerLevel = 0;
style.isList = false;
let parent = element;
while(parent){
const parent_style = window.getComputedStyle(parent);
// stack CSS text-decoration as it is not overridden by children
style.textDecorationStack.push(parent_style.textDecoration);
// check if one parent is a list-item
if(parent_style.display === "list-item") style.isList = true;
// get first header level, if any
if(!style.headerLevel){
for(let i = 1; i <= 6; i++){
if(parent.tagName.toUpperCase() === "H"+i) {
style.headerLevel = i;
break;
}
}
}
parent = parent.parentElement;
}
}
}
emits('update:current-style', style)
current_text_style.value = style;
}
const processCurrentElement = (e) => {
let element = false;
const sel = window.getSelection();
if(sel.focusNode) {
element = sel.focusNode.tagName ? sel.focusNode : sel.focusNode.parentElement;
}
const hasEditorParent = checkForEditorParent(element)
// console.log(element)
if (hasEditorParent) {
emits('update:activeElement', element)
activeElement.value = element
}
}
const checkForEditorParent = (element) => {
if (!element) return false;
let currentElement = element;
// Поднимаемся вверх по DOM дереву и проверяем родителей
while (currentElement && currentElement !== document.body) {
// Проверяем классы
if (currentElement.classList && currentElement.classList.contains('editor')) {
return true;
}
// Проверяем id
if (currentElement.id === 'editor') {
return true;
}
// Переходим к родительскому элементу
currentElement = currentElement.parentElement;
}
return false;
}
// Process the specific style (position and size) of each page <div> and content <div>
const page_style = (page_idx, allow_overflow) => {
const px_in_mm = 0.2645833333333;
const page_width = props.page_format_mm[0] / px_in_mm;
const page_spacing_mm = 10;
const page_with_plus_spacing = (page_spacing_mm + props.page_format_mm[0]) * props.zoom / px_in_mm;
const view_padding = 20;
const inner_width = editor_width.value - 2 * view_padding;
let nb_pages_x = 1, page_column, x_pos, x_ofx, left_px, top_mm, bkg_width_mm, bkg_height_mm;
if(props.display === "horizontal") {
if(inner_width > (pages.value.length * page_with_plus_spacing)){
nb_pages_x = Math.floor(inner_width / page_with_plus_spacing);
left_px = inner_width / (nb_pages_x * 2) * (1 + page_idx * 2) - page_width / 2;
} else {
nb_pages_x = pages.value.length;
left_px = page_with_plus_spacing * page_idx + page_width / 2 * (props.zoom - 1);
}
top_mm = 0;
bkg_width_mm = props.zoom * (props.page_format_mm[0] * nb_pages_x + (nb_pages_x - 1) * page_spacing_mm);
bkg_height_mm = props.page_format_mm[1] * props.zoom;
} else { // "grid", vertical
nb_pages_x = Math.floor(inner_width / page_with_plus_spacing);
if(nb_pages_x < 1 || props.display === "vertical") nb_pages_x = 1;
page_column = (page_idx % nb_pages_x);
x_pos = inner_width / (nb_pages_x * 2) * (1 + page_column * 2) - page_width / 2;
x_ofx = Math.max(0, (page_width * props.zoom - inner_width) / 2);
left_px = x_pos + x_ofx;
top_mm = ((props.page_format_mm[1] + page_spacing_mm) * props.zoom) * Math.floor(page_idx / nb_pages_x);
const nb_pages_y = Math.ceil(pages.value.length / nb_pages_x);
bkg_width_mm = props.zoom * (props.page_format_mm[0] * nb_pages_x + (nb_pages_x - 1) * page_spacing_mm);
bkg_height_mm = props.zoom * (props.page_format_mm[1] * nb_pages_y + (nb_pages_y - 1) * page_spacing_mm);
}
if(page_idx >= 0) {
const style = {
position: "absolute",
left: "calc("+ left_px +"px + "+ view_padding +"px)",
top: "calc("+ top_mm +"mm + "+ view_padding +"px)",
width: props.page_format_mm[0]+"mm",
// "height" is set below
padding: (typeof props.page_margins == "function") ? props.page_margins(page_idx + 1, pages.value.length) : props.page_margins,
transform: "scale("+ props.zoom +")"
};
style[allow_overflow ? "minHeight" : "height"] = props.page_format_mm[1]+"mm";
return style;
} else {
// Content/background <div> is sized so it lets a margin around pages when scrolling at the end
return { width: "calc("+ bkg_width_mm +"mm + "+ (2*view_padding) +"px)", height: "calc("+ bkg_height_mm +"mm + "+ (2*view_padding) +"px)" };
}
}
// Utility to convert page_style to CSS string
const css_to_string = (css) => Object.entries(css).map(([k, v]) => k.replace(/[A-Z]/g, match => ("-"+match.toLowerCase()))+":"+v).join(';')
// Update pages <div> from pages.value data
const update_pages_elts = () => {
// Removing deleted pages
const deleted_pages = [...content.value.children].filter((page_elt) => !pages.value.find(page => (page.elt === page_elt)));
for(const page_elt of deleted_pages) { page_elt.remove(); }
// Adding / updating pages
for(const [page_idx, page] of pages.value.entries()) {
// Get either existing page_elt or create it
if(!page.elt) {
page.elt = document.createElement("div");
page.elt.className = "page";
page.elt.dataset.isVDEPage = "";
const next_page = pages.value[page_idx + 1];
content.value.insertBefore(page.elt, next_page ? next_page.elt : null);
}
// Update page properties
page.elt.dataset.contentIdx = page.content_idx;
if(!printing_mode.value) page.elt.style = Object.entries(page_style(page_idx, page.template ? false : true)).map(([k, v]) => k.replace(/[A-Z]/g, match => ("-"+match.toLowerCase()))+":"+v).join(';'); // (convert page_style to CSS string)
page.elt.contentEditable = (props.editable && !page.template) ? true : false;
}
}
// Get and store empty editor <div> width
const update_editor_width = () => {
editor.value.classList.add("hide_children");
editor_width.value = editor.value.clientWidth;
update_pages_elts();
editor.value.classList.remove("hide_children");
}
const update_css_media_style = () => {
css_media_style.innerHTML = "@media print { @page { size: "+props.page_format_mm[0]+"mm "+props.page_format_mm[1]+"mm; margin: 0 !important; } .hidden-print { display: none !important; } }";
}
// Prepare content before opening the native print box
const before_print = () => {
// set the printing mode flag
printing_mode.value = true;
console.log('start printing')
// store the current body aside
_page_body.value = document.body;
// create a new body for the print and overwrite CSS
const print_body = document.createElement("body");
print_body.style.margin = "0";
print_body.style.padding = "0";
print_body.style.background = "white";
print_body.style.font = window.getComputedStyle(editor.value).font;
print_body.className = editor.value.className;
// move each page to the print body
for(const [page_idx, page] of pages.value.entries()){
//const page_clone = page_elt.cloneNode(true);
page.elt.style = ""; // reset page style for the clone
page.elt.style.position = "relative";
page.elt.style.padding = (typeof props.page_margins == "function") ? props.page_margins(page_idx + 1, pages.value.length) : props.page_margins;
page.elt.style.breakBefore = page_idx ? "page" : "auto";
page.elt.style.width = "calc("+props.page_format_mm[0]+"mm - 2px)";
page.elt.style.height = "calc("+props.page_format_mm[1]+"mm - 2px)";
page.elt.style.boxSizing = "border-box";
page.elt.style.overflow = "hidden";
// add overlays if any
const overlay_elt = pages_overlay_refs[page.uuid];
if(overlay_elt){
overlay_elt.style.position = "absolute";
overlay_elt.style.left = "0";
overlay_elt.style.top = "0";
overlay_elt.style.transform = "none";
overlay_elt.style.padding = "0";
overlay_elt.style.overflow = "hidden";
page.elt.prepend(overlay_elt);
}
print_body.append(page.elt);
}
// display a return arrow to let the user restore the original body in case the navigator doesn't call after_print() (it happens sometimes in Chrome)
// const return_overlay = document.createElement("div");
// return_overlay.className = "hidden-print"; // css managed in update_css_media_style method
// return_overlay.style.position = "fixed";
// return_overlay.style.left = "0";
// return_overlay.style.top = "0";
// return_overlay.style.right = "0";
// return_overlay.style.bottom = "0";
// return_overlay.style.display = "flex";
// return_overlay.style.alignItems = "center";
// return_overlay.style.justifyContent = "center";
// return_overlay.style.background = "rgba(255, 255, 255, 0.95)";
// return_overlay.style.cursor = "pointer";
// return_overlay.innerHTML = '<svg width="220" height="220"><path fill="rgba(0, 0, 0, 0.7)" d="M120.774,179.271v40c47.303,0,85.784-38.482,85.784-85.785c0-47.3-38.481-85.782-85.784-85.782H89.282L108.7,28.286L80.417,0L12.713,67.703l67.703,67.701l28.283-28.284L89.282,87.703h31.492c25.246,0,45.784,20.538,45.784,45.783C166.558,158.73,146.02,179.271,120.774,179.271z"/></svg>'
// return_overlay.addEventListener("click", after_print);
// print_body.append(return_overlay);
// replace current body by the print body
document.body = print_body;
}
// Restore content after closing the native print box
const after_print = () => {
// clear the printing mode flag
printing_mode.value = false;
// restore pages and overlays
for(const [page_idx, page] of pages.value.entries()){
page.elt.style = css_to_string(page_style(page_idx, page.template ? false : true));
content.value.append(page.elt);
const overlay_elt = pages_overlay_refs[page.uuid];
if(overlay_elt) {
overlay_elt.style = css_to_string(page_style(page_idx, false));
overlays.value.append(overlay_elt);
}
}
document.body = _page_body.value;
// recompute editor with and reposition elements
update_editor_width();
}
watch(model, () => {
if(prevent_next_content_update_from_parent.value) {
prevent_next_content_update_from_parent.value = false;
} else reset_content();
}, {
deep: true
})
watch(props.display, () => {
update_pages_elts()
})
watch(props.page_format_mm, () => {
update_css_media_style()
reset_content()
})
watch(props.page_margins, () => {
reset_content()
})
watch(props.zoom, () => {
update_pages_elts()
})
</script>
<style>
body {
/* Enable printing of background colors */
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
</style>
<style scoped>
.editor {
display: block;
font-family: 'Times New Roman', serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: default;
}
.editor ::-webkit-scrollbar {
width: 16px;
height: 16px;
}
.editor ::-webkit-scrollbar-track,
.editor ::-webkit-scrollbar-corner {
display: none;
}
.editor ::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.5);
border: 5px solid transparent;
border-radius: 16px;
background-clip: content-box;
}
.editor ::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.editor .hide_children > * {
display: none;
}
.editor > .content {
position: relative;
outline: none;
margin: 0;
padding: 0;
min-width: 100%;
pointer-events: none;
color: black;
}
.editor > .content > :deep(.page) {
position: absolute;
box-sizing: border-box;
left: 50%;
transform-origin: center top;
background: var(--page-background, white);
box-shadow: var(--page-box-shadow, 0 1px 3px 1px rgba(60, 64, 67, 0.15));
border: var(--page-border);
border-radius: var(--page-border-radius);
transition: left 0.3s, top 0.3s;
overflow: hidden;
pointer-events: all;
}
/* Переменные */
.editor > .content[brs-variable],
.editor > .content :deep(*[brs-variable]) {
background-color: yellow;
text-decoration: underline;
text-decoration-style: dotted;
}
.editor > .content[contenteditable],
.editor > .content :deep(*[contenteditable]) {
cursor: text;
}
.editor > .content :deep(*[contenteditable=false]) {
cursor: default;
}
.editor > .overlays {
position: relative;
margin: 0;
padding: 0;
min-width: 100%;
pointer-events: none;
}
.editor > .overlays > .overlay {
position: absolute;
box-sizing: border-box;
left: 50%;
transform-origin: center top;
transition: left 0.3s, top 0.3s;
overflow: hidden;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
label: {
type: String,
default: null
},
position: {
type: String,
default: 'top'
}
})
const labelPositions = {
left: [
'flex flex-row gap-x-2 items-center'
],
top: [
'flex flex-col gap-y-1'
]
}
const labelPositionClass = computed(() => {
if (props.label)
return labelPositions[props.position]
})
</script>
<template>
<div :class="labelPositionClass">
<label v-if="label" class="text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white">
{{ label }}
</label>
<div class="grow">
<slot />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,35 @@
<script setup>
import Input from "./Input.vue"
import {ref} from "vue";
const props = defineProps({
accept: {
type: String,
default: ''
}
})
const fileRef = ref(null)
const file = defineModel('file')
const fileList = defineModel('fileList')
const onFileChanged = (e) => {
const target = e.target
if (target && target.files) {
fileList.value = target.files
file.value = target.files[0]
}
}
</script>
<template>
<Input ref="fileRef"
type="file"
@change="(e) => onFileChanged(e)"
:accept="accept"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,27 @@
<script setup>
const value = defineModel('value')
const props = defineProps({
label: String,
disabled: {
type: Boolean,
default: false
}
})
</script>
<template>
<div class="flex flex-col">
<span v-if="label" class="text-sm mb-0.5">
{{ label }}
</span>
<input v-model="value"
v-bind="$attrs"
:disabled="disabled"
:data-disabled="disabled"
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-[disabled=true]:border-zinc-950/20 dark:data-[disabled=true]:border-white/15 data-[disabled=true]:text-zinc-950/35 dark:data-[disabled=true]:text-white/35 dark:data-[disabled=true]:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark" />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,92 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import {useDebounceFn} from "@vueuse/core";
const props = defineProps({
placeholders: {
type: Array,
default: () => [
'Поиск по шаблонам...',
]
},
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const value = ref(props.modelValue)
const currentPlaceholderIndex = ref(0)
const isAnimating = ref(false)
const showPlaceholder = ref(true)
const debounceUpdate = useDebounceFn((value) => {
emit('update:modelValue', value)
}, 800)
// Автоматическое обновление modelValue
watch(value, (newVal) => {
debounceUpdate(newVal)
showPlaceholder.value = newVal === ''
})
watch(() => props.modelValue, (newVal) => {
value.value = newVal
})
// Анимация placeholder
const animatePlaceholder = () => {
if (isAnimating.value) return
isAnimating.value = true
setTimeout(() => {
currentPlaceholderIndex.value = (currentPlaceholderIndex.value + 1) % props.placeholders.length
isAnimating.value = false
}, 3000) // Меняем каждые 3 секунды
}
onMounted(() => {
animatePlaceholder()
setInterval(animatePlaceholder, 3500) // Интервал анимации
})
const currentPlaceholder = computed(() => props.placeholders[currentPlaceholderIndex.value])
</script>
<template>
<div class="relative w-full">
<input
v-model="value"
type="text"
:placeholder="currentPlaceholder"
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark"
/>
<div v-if="showPlaceholder" class="absolute left-1 inset-0 pointer-events-none overflow-hidden">
<div
v-for="(ph, index) in placeholders"
:key="ph"
:class="[
'absolute inset-0 flex items-center px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)] text-zinc-500 transition-all duration-500',
index === currentPlaceholderIndex ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0'
]"
>
<span class="text-base/6 sm:text-sm/6">{{ ph }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* Дополнительные стили для плавной анимации */
.absolute > div {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
input::placeholder {
opacity: 0; /* Скрываем стандартный placeholder */
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup>
const value = defineModel('value')
const props = defineProps({
label: String,
disabled: {
type: Boolean,
default: false
},
rows: {
type: Number,
default: 4
},
maxLength: Number,
resize: {
type: Boolean,
default: true
}
})
</script>
<template>
<div class="flex flex-col">
<span v-if="label" class="text-sm mb-0.5">
{{ label }}
</span>
<textarea v-model="value"
v-bind="$attrs"
:disabled="disabled"
:data-disabled="disabled"
:rows="rows"
:maxlength="maxLength"
:class="{ 'resize-none': !resize }"
class="relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white border border-zinc-950/10 hover:border-zinc-950/20 focus:border-zinc-950/20 dark:border-white/10 dark:hover:border-white/20 dark:focus:border-white/20 bg-transparent dark:bg-white/5 focus:outline-hidden data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600 data-[disabled=true]:border-zinc-950/20 dark:data-[disabled=true]:border-white/15 data-[disabled=true]:text-zinc-950/35 dark:data-[disabled=true]:text-white/35 dark:data-[disabled=true]:bg-white/2.5 dark:data-hover:data-disabled:border-white/15 dark:scheme-dark resize-vertical min-h-[80px]">
</textarea>
</div>
</template>
<style scoped>
textarea {
font-family: inherit;
}
</style>

View File

@@ -0,0 +1,35 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
vertical: {
type: String,
default: 'vertical'
}
})
const verticalClasses = [
'flex', 'flex-col', 'gap-y-2'
]
const horizontalClasses = [
'grid', 'grid-cols-3', 'gap-y-2', 'gap-x-2', 'grow', 'items-start'
]
const classes = computed(() => {
if (props.vertical)
return verticalClasses
return horizontalClasses
})
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,31 @@
<script setup>
import {onMounted, ref} from "vue";
const componentRef = ref()
</script>
<template>
<div ref="componentRef"
@mouseenter="componentRef.setAttribute('data-hover', '')"
@mouseleave="componentRef.removeAttribute('data-hover')"
class="flex flex-col max-lg:flex-row gap-y-1 px-2 sm:py-2 py-2.5 rounded-md transition-all data-hover:bg-zinc-950/5 dark:data-hover:bg-white/5 active:scale-[.99]">
<div v-if="$slots.header || $slots.actions" class="flex justify-between items-center">
<div v-if="$slots.header" class="text-sm font-medium">
<slot name="header" />
</div>
<div v-if="$slots.actions">
<slot name="actions" />
</div>
</div>
<div v-if="$slots.default">
<slot />
</div>
<div v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,21 @@
<script setup>
const props = defineProps({
header: {
type: String,
}
})
</script>
<template>
<div class="px-3 pt-2 pb-4 first:rounded-t-lg first:rounded-b-sm lg:ring-1 lg:ring-zinc-950/10 dark:lg:ring-white/10 last:rounded-b-lg last:rounded-t-sm not-last:not-first:rounded-sm not-last:not-first:my-1 bg-white dark:text-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-800">
<slot v-if="$slots.header" name="header" />
<span v-else class="block text-sm font-medium mb-2">
{{ header }}
</span>
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,180 @@
<script setup>
import {computed, ref, watch} from 'vue'
const props = defineProps({
open: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
titleId: {
type: String,
default: () => `modal-title-${Math.random().toString(36).substr(2, 9)}`
},
descriptionId: {
type: String,
default: () => `modal-description-${Math.random().toString(36).substr(2, 9)}`
},
panelId: {
type: String,
default: () => `modal-panel-${Math.random().toString(36).substr(2, 9)}`
},
closeButton: {
type: Boolean,
default: true
},
width: {
type: Number,
default: 512
}
})
const emit = defineEmits(['close', 'beforeClose', 'afterClose'])
// Блокировка скролла при открытии модального окна
watch(() => props.open, (isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
// Ширина модального окна
const modalWidth = computed(() => {
if (props.width === 0 || props.width === null)
return `width: 512px`
else
return `width: ${props.width}px`
})
// Очистка при размонтировании
import { onUnmounted } from 'vue'
import Button from "../Button/Button.vue";
onUnmounted(() => {
document.body.style.overflow = ''
})
// Стили модального окна
const styles = computed(() => [
modalWidth.value
])
const close = () => {
emit('beforeClose')
emit('close')
emit('afterClose')
}
</script>
<template>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="open"
role="dialog"
tabindex="-1"
aria-modal="true"
:data-headlessui-state="open ? 'open' : 'closed'"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
class="fixed inset-0 z-50"
>
<!-- Backdrop -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
aria-hidden="true"
:data-headlessui-state="open ? 'open' : 'closed'"
@click="$emit('close')"
/>
</transition>
<!-- Modal content -->
<div class="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
<div class="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
enter-to-class="translate-y-0 opacity-100 sm:scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100 sm:scale-100"
leave-to-class="translate-y-12 opacity-0 sm:translate-y-0 sm:scale-95"
>
<div
v-if="open"
class="transition-[width] delay-0 duration-300 row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline will-change-transform"
:style="styles"
:id="panelId"
:data-headlessui-state="open ? 'open' : 'closed'"
>
<div class="flex flex-row justify-between items-center">
<!-- Title slot -->
<slot name="title" :titleId="titleId">
<h2
v-if="title"
class="text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white"
:id="titleId"
>
{{ title }}
</h2>
</slot>
<slot name="close-button">
<Button icon v-if="closeButton" @click="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>
</Button>
</slot>
</div>
<!-- Description slot -->
<slot name="description" :descriptionId="descriptionId">
<p
v-if="description"
class="mt-2 text-pretty text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400"
:id="descriptionId"
>
{{ description }}
</p>
</slot>
<!-- Default content slot -->
<div class="mt-6 max-h-[520px] overflow-y-auto p-0.5 pr-2">
<slot></slot>
</div>
<!-- Actions slot -->
<div class="mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto">
<slot name="actions"></slot>
</div>
</div>
</transition>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,142 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import Card from '../Card/Card.vue'
import Button from "../Button/Button.vue";
import Collapsible from "../Collapsible/Collapsible.vue";
// Пропсы
const props = defineProps({
customTitle: {
type: String,
default: ''
},
customDescription: {
type: String,
default: ''
},
autoShow: {
type: Boolean,
default: true
}
})
// Реактивные данные
const isVisible = ref(false)
const headerText = 'Уведомление о сборе данных'
const title = computed(() => props.customTitle || 'Мониторинг ошибок')
const description = computed(() => props.customDescription || 'Мы используем Sentry для отслеживания и исправления ошибок на сайте. Это помогает нам улучшать качество сервиса.')
// Собираемые данные
const collectedData = ref([
'Текст ошибки и стектрейс',
'Тип браузера и версия',
'Операционная система',
'URL страницы где произошла ошибка',
'Временная метка ошибки',
'Действия пользователя перед ошибкой',
'Размер экрана устройства',
'Анонимизированный идентификатор сессии'
])
// События
const emit = defineEmits(['accept', 'learnMore', 'close'])
const handleAccept = () => {
localStorage.setItem('sentry-notification-accepted', 'true')
isVisible.value = false
emit('accept')
}
const handleLearnMore = () => {
emit('learnMore')
window.open('https://sentry.io/features/error-monitoring/', '_blank')
}
// Показывать уведомление только если пользователь еще не соглашался
onMounted(() => {
if (props.autoShow && !localStorage.getItem('sentry-notification-accepted')) {
isVisible.value = true
}
})
// Экспортируем методы для управления видимостью
defineExpose({
show: () => isVisible.value = true,
hide: () => isVisible.value = false
})
</script>
<template>
<div v-if="isVisible" class="fixed bottom-12 right-5 z-50 w-96">
<Card
:header="headerText"
:content-scroll="false"
>
<!-- Компактный заголовок -->
<div class="flex items-center space-x-3 p-3">
<div class="flex-shrink-0 w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-slate-900">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M3 18a1.93 1.93 0 0 0 .306 1.076a2 2 0 0 0 1.584 .924c.646 .033 -.537 0 .11 0h3a4.992 4.992 0 0 0 -3.66 -4.81c.558 -.973 1.24 -2.149 2.04 -3.531a9 9 0 0 1 5.62 8.341h4c.663 0 2.337 0 3 0a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-1.84 3.176c4.482 2.05 7.6 6.571 7.6 11.824" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white truncate">
{{ title }}
</h3>
<p class="text-xs text-slate-600 dark:text-slate-300 truncate">
Мы используем Sentry для анализа ошибок
</p>
</div>
</div>
<!-- Собираемые данные в аккордеоне -->
<div class="px-3 pb-3">
<Collapsible header="Какие данные собираем">
<div class="space-y-1 max-h-32 overflow-y-auto">
<div
v-for="(item, index) in collectedData"
:key="index"
class="flex items-start space-x-2 text-xs"
>
<div class="flex-shrink-0 w-3 h-3 text-emerald-500 mt-0.5">
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</div>
<span class="text-slate-600 dark:text-slate-300 leading-relaxed">
{{ item }}
</span>
</div>
</div>
</Collapsible>
</div>
<!-- Футер с действиями -->
<template #footer>
<div class="flex space-x-2">
<!-- <Button-->
<!-- variant="ghost"-->
<!-- text-align="center"-->
<!-- block-->
<!-- @click="handleLearnMore"-->
<!-- >-->
<!-- Подробнее-->
<!-- </Button>-->
<Button
text-align="center"
block
@click="handleAccept"
>
Понятно
</Button>
</div>
</template>
</Card>
</div>
</template>
<style scoped>
/* Дополнительные кастомные стили если нужно */
</style>

View File

@@ -0,0 +1,14 @@
<script setup>
</script>
<template>
<div class="mx-auto max-w-3xl">
<slot name="header" />
<slot />
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div class="py-2">
<slot />
</div>
</template>
<style scoped>
</style>

Some files were not shown because too many files have changed in this diff Show More