first commit
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
65
.env.example
Normal 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
11
.gitattributes
vendored
Normal 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
24
.gitignore
vendored
Normal 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
|
||||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
289
app/Http/Controllers/DocImportController.php
Normal file
289
app/Http/Controllers/DocImportController.php
Normal 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| )*<\/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Http/Controllers/DocumentController.php
Normal file
90
app/Http/Controllers/DocumentController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Controllers/DocumentEditorController.php
Normal file
24
app/Http/Controllers/DocumentEditorController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Http/Controllers/EditorController.php
Normal file
21
app/Http/Controllers/EditorController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Http/Controllers/WorkspaceController.php
Normal file
19
app/Http/Controllers/WorkspaceController.php
Normal 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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Http/Middleware/HandleInertiaRequests.php
Normal file
43
app/Http/Middleware/HandleInertiaRequests.php
Normal 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),
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Models/DocumentTemplate.php
Normal file
68
app/Models/DocumentTemplate.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Models/DocumentTemplateVariable.php
Normal file
10
app/Models/DocumentTemplateVariable.php
Normal 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
48
app/Models/User.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal 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
644
app/Services/DocxParser.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Services/DocxTemplateProcessor.php
Normal file
56
app/Services/DocxTemplateProcessor.php
Normal 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; // только . и ..
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/Services/DocxVariableExtractor.php
Normal file
249
app/Services/DocxVariableExtractor.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
239
app/Services/PageBreaker.php
Normal file
239
app/Services/PageBreaker.php
Normal 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
18
artisan
Executable 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
23
bootstrap/app.php
Normal 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
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
5
bootstrap/providers.php
Normal file
5
bootstrap/providers.php
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
];
|
||||||
79
composer.json
Normal file
79
composer.json
Normal 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
9155
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal 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
115
config/auth.php
Normal 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
108
config/cache.php
Normal 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
174
config/database.php
Normal 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
80
config/filesystems.php
Normal 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
11
config/libreoffice.php
Normal 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
132
config/logging.php
Normal 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
118
config/mail.php
Normal 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
112
config/queue.php
Normal 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
84
config/sanctum.php
Normal 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
135
config/sentry.php
Normal 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
38
config/services.php
Normal 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
217
config/session.php
Normal 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
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
23
database/seeders/DatabaseSeeder.php
Normal file
23
database/seeders/DatabaseSeeder.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
database/seeders/DocumentTemplateSeeder.php
Normal file
208
database/seeders/DocumentTemplateSeeder.php
Normal 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
53
nginx.dev.conf
Normal 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
4312
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
34
phpunit.xml
Normal 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
25
public/.htaccess
Normal 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>
|
||||||
BIN
public/assets/fonts/Golos-Text_Black.woff2
Normal file
BIN
public/assets/fonts/Golos-Text_Black.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/Golos-Text_Bold.woff2
Normal file
BIN
public/assets/fonts/Golos-Text_Bold.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/Golos-Text_DemiBold.woff2
Normal file
BIN
public/assets/fonts/Golos-Text_DemiBold.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/Golos-Text_Medium.woff2
Normal file
BIN
public/assets/fonts/Golos-Text_Medium.woff2
Normal file
Binary file not shown.
BIN
public/assets/fonts/Golos-Text_Regular.woff2
Normal file
BIN
public/assets/fonts/Golos-Text_Regular.woff2
Normal file
Binary file not shown.
0
public/favicon.ico
Normal file
0
public/favicon.ico
Normal file
20
public/index.php
Normal file
20
public/index.php
Normal 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
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
62
resources/css/app.css
Normal file
62
resources/css/app.css
Normal 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;
|
||||||
|
}
|
||||||
38
resources/js/Components/Accordion/Accordion.vue
Normal file
38
resources/js/Components/Accordion/Accordion.vue
Normal 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>
|
||||||
91
resources/js/Components/Badge/Badge.vue
Normal file
91
resources/js/Components/Badge/Badge.vue
Normal 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>
|
||||||
212
resources/js/Components/Button/Button.vue
Normal file
212
resources/js/Components/Button/Button.vue
Normal 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>
|
||||||
433
resources/js/Components/Calendar/Calendar.vue
Normal file
433
resources/js/Components/Calendar/Calendar.vue
Normal 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>
|
||||||
65
resources/js/Components/Card/Card.vue
Normal file
65
resources/js/Components/Card/Card.vue
Normal 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>
|
||||||
32
resources/js/Components/Card/CardBack.vue
Normal file
32
resources/js/Components/Card/CardBack.vue
Normal 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>
|
||||||
11
resources/js/Components/Card/CardBody.vue
Normal file
11
resources/js/Components/Card/CardBody.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup>
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
13
resources/js/Components/Card/CardFooter.vue
Normal file
13
resources/js/Components/Card/CardFooter.vue
Normal 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>
|
||||||
5
resources/js/Components/Card/CardHeader.vue
Normal file
5
resources/js/Components/Card/CardHeader.vue
Normal 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>
|
||||||
130
resources/js/Components/Collapsible/Collapsible.vue
Normal file
130
resources/js/Components/Collapsible/Collapsible.vue
Normal 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>
|
||||||
45
resources/js/Components/Document/A4.vue
Normal file
45
resources/js/Components/Document/A4.vue
Normal 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>
|
||||||
61
resources/js/Components/Document/DocumentElement.vue
Normal file
61
resources/js/Components/Document/DocumentElement.vue
Normal 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>
|
||||||
54
resources/js/Components/Document/DocumentHeading.vue
Normal file
54
resources/js/Components/Document/DocumentHeading.vue
Normal 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>
|
||||||
35
resources/js/Components/Document/DocumentHtml.vue
Normal file
35
resources/js/Components/Document/DocumentHtml.vue
Normal 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>
|
||||||
17
resources/js/Components/Document/DocumentLineBreak.vue
Normal file
17
resources/js/Components/Document/DocumentLineBreak.vue
Normal 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>
|
||||||
139
resources/js/Components/Document/DocumentParagraph.vue
Normal file
139
resources/js/Components/Document/DocumentParagraph.vue
Normal 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>
|
||||||
107
resources/js/Components/Document/DocumentRenderer.vue
Normal file
107
resources/js/Components/Document/DocumentRenderer.vue
Normal 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>
|
||||||
40
resources/js/Components/Document/DocumentTable.vue
Normal file
40
resources/js/Components/Document/DocumentTable.vue
Normal 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>
|
||||||
13
resources/js/Components/Document/DocumentTableCell.vue
Normal file
13
resources/js/Components/Document/DocumentTableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
element: Object
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
29
resources/js/Components/Document/DocumentTableRow.vue
Normal file
29
resources/js/Components/Document/DocumentTableRow.vue
Normal 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>
|
||||||
93
resources/js/Components/Document/DocumentText.vue
Normal file
93
resources/js/Components/Document/DocumentText.vue
Normal 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>
|
||||||
141
resources/js/Components/Document/DocumentTextRun.vue
Normal file
141
resources/js/Components/Document/DocumentTextRun.vue
Normal 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>
|
||||||
24
resources/js/Components/Document/DocumentWarning.vue
Normal file
24
resources/js/Components/Document/DocumentWarning.vue
Normal 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>
|
||||||
136
resources/js/Components/Document/InputVariable/PriceInput.vue
Normal file
136
resources/js/Components/Document/InputVariable/PriceInput.vue
Normal 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>
|
||||||
753
resources/js/Components/Editor.vue
Normal file
753
resources/js/Components/Editor.vue
Normal 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>
|
||||||
43
resources/js/Components/Form/FormGroup.vue
Normal file
43
resources/js/Components/Form/FormGroup.vue
Normal 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>
|
||||||
35
resources/js/Components/Input/FileUpload.vue
Normal file
35
resources/js/Components/Input/FileUpload.vue
Normal 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>
|
||||||
27
resources/js/Components/Input/Input.vue
Normal file
27
resources/js/Components/Input/Input.vue
Normal 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>
|
||||||
92
resources/js/Components/Input/Search/AnimateSearch.vue
Normal file
92
resources/js/Components/Input/Search/AnimateSearch.vue
Normal 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>
|
||||||
42
resources/js/Components/Input/TextArea.vue
Normal file
42
resources/js/Components/Input/TextArea.vue
Normal 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>
|
||||||
35
resources/js/Components/List/List.vue
Normal file
35
resources/js/Components/List/List.vue
Normal 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>
|
||||||
31
resources/js/Components/List/ListItem.vue
Normal file
31
resources/js/Components/List/ListItem.vue
Normal 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>
|
||||||
21
resources/js/Components/List/ListStrate.vue
Normal file
21
resources/js/Components/List/ListStrate.vue
Normal 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>
|
||||||
180
resources/js/Components/Modal/Modal.vue
Normal file
180
resources/js/Components/Modal/Modal.vue
Normal 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>
|
||||||
142
resources/js/Components/Notifications/SentryNotification.vue
Normal file
142
resources/js/Components/Notifications/SentryNotification.vue
Normal 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>
|
||||||
14
resources/js/Components/Page/Page.vue
Normal file
14
resources/js/Components/Page/Page.vue
Normal 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>
|
||||||
13
resources/js/Components/Page/PageBody.vue
Normal file
13
resources/js/Components/Page/PageBody.vue
Normal 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
Reference in New Issue
Block a user