Compare commits

...
Sign in to create a new pull request.

18 commits

Author SHA1 Message Date
Jérémy Munsch
e9aeeeea93
Fix ModelLinkComponent compile ts 2022-07-15 12:07:38 +02:00
Jérémy Munsch
1a4852432e
phpcs fix 2022-07-13 17:55:34 +02:00
Jérémy Munsch
d7661da346
Fix typo 2022-07-13 17:53:22 +02:00
Jérémy Munsch
83f39a2c87
model link vue select translation 2022-07-13 17:53:21 +02:00
Jérémy Munsch
aecab74e93
Product crud 2022-07-13 17:53:21 +02:00
Jérémy Munsch
91c3f379ec
Peacock color 2022-07-13 17:53:20 +02:00
Jérémy Munsch
204fc40469
local security checker + composer update 2022-07-13 17:53:20 +02:00
Jérémy Munsch
47c2a9d924
Ranges CRUD OK 2022-07-13 17:53:20 +02:00
Jérémy Munsch
18ca33e3a7
ChangesModelOrder trait 2022-07-13 17:53:19 +02:00
Jérémy Munsch
00287eadf2
Add multiple models + seeders
- Breeder
- Range
- Product
- Recipe
- IncoNutritionalValue
2022-07-13 17:53:18 +02:00
Vincent BRIDIER
67e6892c21
add CI 2022-07-13 15:41:11 +02:00
Vincent BRIDIER
ff80b26d9f
fix ticket 974 2022-07-13 15:40:59 +02:00
Vincent BRIDIER
aa3c0adf69
add version laravel mix 2022-07-13 15:40:17 +02:00
Vincent BRIDIER
7e8ce36ce7 Merge branch 'retourSprint1' into 'develop'
Retour sprint1

See merge request service-web-lab/loue!4
2022-07-04 10:17:34 +00:00
Jérémy Munsch
2e033368e2 Retour sprint 1 2022-07-04 10:17:34 +00:00
Vincent BRIDIER
a10600adb1 Merge into 'develop' 2022-06-21 07:14:00 +00:00
Jérémy Munsch
74f60c40d8 Retours sprint 1 2022-06-21 07:14:00 +00:00
Vincent BRIDIER
17df77b236 Merge branch '<(o_O)>_de_projet' into 'develop' 2022-06-13 09:59:37 +00:00
116 changed files with 6994 additions and 1899 deletions

View file

@ -14,7 +14,7 @@ trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{vue}]
[*.vue]
indent_size = 2
[docker-compose.yml]

29
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,29 @@
image: jakzal/phpqa:php7.3
stages:
- Security
- CodingStandards
security:
stage: Security
script:
- local-php-security-checker --format=json --path=./composer.lock > security-checker-report.json
artifacts:
when: on_failure
expose_as: 'security-checker'
paths:
- security-checker-report.json
allow_failure: true
phpcs:
stage: CodingStandards
script:
- phpcs --report=junit --report-file=phpcs-report.xml
artifacts:
when: on_failure
expose_as: 'phpcs'
paths:
- phpcs-report.xml
reports:
junit: phpcs-report.xml
allow_failure: false

23
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,23 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#595926",
"activityBar.activeBorder": "#439c9c",
"activityBar.background": "#595926",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#439c9c",
"activityBarBadge.foreground": "#15202b",
"sash.hoverBorder": "#595926",
"statusBar.background": "#353517",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#595926",
"statusBarItem.remoteBackground": "#353517",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#353517",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#35351799",
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.remoteColor": "#353517",
"vue3snippets.enable-compile-vue-file-on-did-save-code": true
}

View file

@ -25,6 +25,7 @@ class HtmlColor implements CastsAttributes
*/
public function get($model, $key, $value, $attributes)
{
// phpcs:enable
return "#" . $this->zeropad(dechex($value), 6);
}
@ -36,9 +37,14 @@ class HtmlColor implements CastsAttributes
* @param mixed $value
* @param array $attributes
* @return integer
* @phpcs:disable Squiz.Commenting.FunctionComment.TypeHintMissing
* @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
*/
public function set($model, $key, $value, $attributes)
{
// phpcs:enable
return \intval(hexdec(ltrim($value, '#')));
}
@ -49,7 +55,7 @@ class HtmlColor implements CastsAttributes
* @param integer $lim
* @return string
*/
private function zeropad(string $num, int $lim): string
protected function zeropad(string $num, int $lim): string
{
return (strlen($num) >= $lim) ? $num : $this->zeropad("0" . $num, $lim);
}

62
app/Casts/RgbaColor.php Normal file
View file

@ -0,0 +1,62 @@
<?php
namespace App\Casts;
/**
* Custom cast RGBA Color
* https://laravel.com/docs/8.x/eloquent-mutators#custom-casts
*/
class RgbaColor extends HtmlColor
{
/**
* Cast the given value.
*
* @param Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return string
* @phpcs:disable Squiz.Commenting.FunctionComment.TypeHintMissing
* @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed
*/
public function get($model, $key, $value, $attributes)
{
// phpcs:enable
$intValues = collect(\str_split($this->zeropad(\dechex(\intval($value)), 8), 2))->map(function ($hex) {
return intval(\hexdec($hex));
});
// * Set Transparency as float
$hexString = $intValues->push(\round($intValues->pop() / 100, 2))->implode(',');
return "rgba($hexString)";
}
/**
* Prepare the given value for storage.
*
* @param Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return integer
* @phpcs:disable Squiz.Commenting.FunctionComment.TypeHintMissing
* @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed
*/
public function set($model, $key, $value, $attributes)
{
// phpcs:enable
$value = \rtrim(\ltrim(\str_replace(' ', '', \str_replace('rgba', '', \strval($value))), '('), ')');
$intValues = collect(\explode(',', $value))->map(function ($val) {
return \floatval($val);
});
// * Set Transparency as int
$hexConcat = $intValues->push($intValues->pop() * 100)->map(function ($value) {
return $this->zeropad(dechex(\intval($value)), 2);
})->implode('');
return \intval(\hexdec($hexConcat));
}
}

View file

@ -2,15 +2,16 @@
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Carbon;
use App\Casts\RgbaColor as CastsRgbaColor;
//phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed
//phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed
class NiceDate implements CastsAttributes
/**
* Custom cast RGBA Color nullable
* https://laravel.com/docs/8.x/eloquent-mutators#custom-casts
*/
class RgbaColorNullable extends CastsRgbaColor
{
/**
* Return a nice human readable carbon date.
* Cast the given value.
*
* @param Model $model
* @param string $key
@ -22,26 +23,24 @@ class NiceDate implements CastsAttributes
*/
public function get($model, $key, $value, $attributes)
{
$value = Carbon::parse($value);
return $value->isToday() ?
trans('Aujourd\'hui à :time', [
'time' => $value->isoFormat('HH:mm')
]) : $value->isoFormat('YYYY/MM/DD HH:mm');
// phpcs:enable
return \is_null($value) ? $value : parent::get($model, $key, $value, $attributes);
}
/**
* Parse a date string to a date time date.
* Prepare the given value for storage.
*
* @param Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return string
* @return integer
* @phpcs:disable Squiz.Commenting.FunctionComment.TypeHintMissing
* @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing
*/
public function set($model, $key, $value, $attributes)
{
return Carbon::parse($value)->isoFormat('YYYY/MM/DD HH:mm');
// phpcs:enable
return \is_null($value) ? $value : parent::set($model, $key, $value, $attributes);
}
}

View file

@ -2,6 +2,8 @@
namespace App\Console;
use App\Jobs\RangesStatusUpdate;
use App\Jobs\ProductStatusUpdate;
use App\Jobs\SlidersStatusUpdate;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@ -17,6 +19,8 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule)
{
$schedule->job(SlidersStatusUpdate::class)->everyMinute();
$schedule->job(RangesStatusUpdate::class)->everyMinute();
$schedule->job(ProductStatusUpdate::class)->everyMinute();
}
/**

View file

@ -0,0 +1,47 @@
<?php
namespace App\Enums\Products;
use Kwaadpepper\Enum\BaseEnum;
/**
* @method static self bio()
* @method static self lr()
* @method static self bea()
* @method static self nutriscore()
* @method static self planetscore()
*/
final class ProductLabelEnum extends BaseEnum
{
/**
* Enum values
*
* @return array
*/
protected static function values(): array
{
return [
'bio' => 0,
'lr' => 1,
'bea' => 2,
'nutriscore' => 3,
'planetscore' => 4
];
}
/**
* Enum labels
*
* @return array
*/
protected static function labels(): array
{
return [
'bio' => trans('Bio'),
'lr' => trans('Label Rouge'),
'bea' => trans('Bea'),
'nutriscore' => trans('Nutriscore'),
'planetscore' => trans('PlanetScore'),
];
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Enums\Ranges;
use Kwaadpepper\Enum\BaseEnum;
/**
* @method static self poultry()
* @method static self egg()
* @method static self partners()
*/
final class RangeCategoryEnum extends BaseEnum
{
/**
* Enum values
*
* @return array
*/
protected static function values(): array
{
return [
'poultry' => 0,
'egg' => 1,
'partners' => 2
];
}
/**
* Enum labels
*
* @return array
*/
protected static function labels(): array
{
return [
'poultry' => trans('🐔 Vollaille'),
'egg' => trans('🥚 Oeuf'),
'partners' => trans('🤝 Produits partenaires'),
];
}
}

View file

@ -9,7 +9,7 @@ use Kwaadpepper\Enum\BaseEnum;
* @method static self center()
* @method static self right()
*/
final class SliderPosition extends BaseEnum
final class SliderElementPosition extends BaseEnum
{
/**
* Enum values

View file

@ -59,7 +59,7 @@ class NewsController extends Controller
public function create()
{
/** @var \Illuminate\Database\Eloquent\Collection */
$tags = Tag::query()->select(['id', 'name'])->get();
$tags = Tag::query()->select(['id', 'name', 'slug'])->get();
return view('back.news.create', compact('tags'));
}
@ -78,7 +78,7 @@ class NewsController extends Controller
if ($news->saveOrFail()) {
$news->tags()->sync(collect($request->tags)->pluck('id'));
return redirect()
->route('back.news.edit', $news)
->route('bo.news.edit', $news)
->with('success', trans(
':model a été créé',
['model' => Str::singular(trans('models.classes.news'))]
@ -98,7 +98,7 @@ class NewsController extends Controller
{
/** @var \Illuminate\Database\Eloquent\Collection */
$tags = Tag::whereNotIn('id', $news->tags()->pluck('id'))
->select(['id', 'name'])->get();
->select(['id', 'name', 'slug'])->get();
return view('back.news.edit', [
'newsModel' => $news,

View file

@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Products\StoreProductRequest;
use App\Http\Requests\Bo\Products\UpdateProductRequest;
use App\Models\Product;
use App\Models\Range;
use App\Traits\Controllers\UpdateModelStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ProductController extends Controller
{
use UpdateModelStatus;
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
/** @var \Illuminate\Database\Eloquent\Builder $products */
$products = Product::query();
if ($request->search) {
$this->searchQuery(
$products,
$request->search,
// * Search in tags
function ($q) use ($request) {
$q->orWhereHas('tags', function ($q) use ($request) {
$q->whereRaw(
'UPPER(CONCAT(`tags`.`name`)) LIKE UPPER(?)',
['%' . filter_var($request->search, FILTER_SANITIZE_STRING) . '%']
);
});
},
'title',
'description'
);
}
$this->sortQuery($products);
$searchFields = trans('Titre, Description');
$products->orderBy('updated_at', 'DESC');
$products = $products->paginate();
return view('back.products.index', compact('products', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$ranges = Range::all();
return view('back.products.create', compact('ranges'));
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Products\StoreProductRequest $request
* @return \Illuminate\Http\Response
*/
public function store(StoreProductRequest $request)
{
return DB::transaction(function () use ($request) {
$product = new Product();
$product->fill($request->validated());
if ($product->saveOrFail()) {
return redirect()
->route('bo.products.edit', $product)
->with('success', trans(
':model a été créé',
['model' => Str::singular(trans('models.classes.products'))]
));
}
return back();
});
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Product $product
* @return \Illuminate\Http\Response
*/
public function edit(Product $product)
{
$ranges = Range::all();
return view('back.products.edit', [
'productModel' => $product,
'ranges' => $ranges,
]);
}
/**
* Update the specified resource in storage.
*
* @param \App\Http\Requests\Bo\Products\UpdateProductRequest $request
* @param \App\Models\Product $product
* @return \Illuminate\Http\Response
*/
public function update(UpdateProductRequest $request, Product $product)
{
return DB::transaction(function () use ($request, $product) {
$product->fill($request->validated());
/** @var \App\Models\IncoNutritionalValue */
$nut = $product->incoNutritionalValues()->firstOrNew();
$nut->fill($request->validated());
$nut->saveOrFail();
if ($product->saveOrFail()) {
return redirect()
->route('bo.products.edit', $product)
->with('success', trans(
':model a été mis à jour',
['model' => Str::singular(trans('models.classes.products'))]
));
}
return back();
});
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Product $product
* @return \Illuminate\Http\Response
*/
public function destroy(Product $product)
{
if ($product->deleteOrFail()) {
return redirect()
->route('bo.products.index')
->with('warning', trans(
':model a bien été supprimé',
['model' => Str::singular(trans('models.classes.products'))]
));
}
return back();
}
}

View file

@ -0,0 +1,197 @@
<?php
namespace App\Http\Controllers\Bo;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\Ranges\RangeModelTitleRequest;
use App\Http\Requests\Bo\Ranges\RangeSearchUriRequest;
use App\Http\Requests\Bo\Ranges\StoreRangeRequest;
use App\Http\Requests\Bo\Ranges\UpdateRangeRequest;
use App\Http\Resources\UriResource;
use App\Models\Breeder;
use App\Models\Range;
use App\Models\Recipe;
use App\Traits\Controllers\UpdateModelStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class RangeController extends Controller
{
use UpdateModelStatus;
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
/** @var \Illuminate\Database\Eloquent\Builder $ranges */
$ranges = Range::query();
if ($request->search) {
$this->searchQuery(
$ranges,
$request->search,
// * Search in tags
function ($q) use ($request) {
$q->orWhereHas('tags', function ($q) use ($request) {
$q->whereRaw(
'UPPER(CONCAT(`tags`.`name`)) LIKE UPPER(?)',
['%' . filter_var($request->search, FILTER_SANITIZE_STRING) . '%']
);
});
},
'title',
'description'
);
}
$this->sortQuery($ranges);
$searchFields = trans('Titre, Description');
$ranges->orderBy('updated_at', 'DESC');
$ranges = $ranges->paginate();
return view('back.ranges.index', compact('ranges', 'searchFields'));
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
return view('back.ranges.create');
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Ranges\StoreRangeRequest $request
* @return \Illuminate\Http\Response
*/
public function store(StoreRangeRequest $request)
{
return DB::transaction(function () use ($request) {
$range = new Range();
$range->fill($request->validated());
if ($range->saveOrFail()) {
return redirect()
->route('bo.ranges.edit', $range)
->with('success', trans(
':model a été créé',
['model' => Str::singular(trans('models.classes.ranges'))]
));
}
return back();
});
}
/**
* Show the form for editing the specified resource.
*
* @param \App\Models\Range $range
* @return \Illuminate\Http\Response
*/
public function edit(Range $range)
{
return view('back.ranges.edit', [
'rangeModel' => $range
]);
}
/**
* Update the specified resource in storage.
*
* @param \App\Http\Requests\Bo\Ranges\UpdateRangeRequest $request
* @param \App\Models\Range $range
* @return \Illuminate\Http\Response
*/
public function update(UpdateRangeRequest $request, Range $range)
{
return DB::transaction(function () use ($request, $range) {
$range->fill($request->validated());
if ($range->saveOrFail()) {
return redirect()
->route('bo.ranges.edit', $range)
->with('success', trans(
':model a été mis à jour',
['model' => Str::singular(trans('models.classes.ranges'))]
));
}
return back();
});
}
/**
* Remove the specified resource from storage.
*
* @param \App\Models\Range $range
* @return \Illuminate\Http\Response
*/
public function destroy(Range $range)
{
if ($range->deleteOrFail()) {
return redirect()
->route('bo.ranges.index')
->with('warning', trans(
':model a bien été supprimé',
['model' => Str::singular(trans('models.classes.ranges'))]
));
}
return back();
}
/**
* Search uris for ranges input links
*
* @param \App\Http\Requests\Bo\Ranges\RangeSearchUriRequest $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function searchUri(RangeSearchUriRequest $request)
{
/** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Breeder> */
$breeders = Breeder::where('firstname', 'LIKE', "%$request->search%")
->orWhere('lastname', 'LIKE', "%$request->search%")
->orderBy('updated_at')->paginate();
/** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Recipe> */
$recipes = Recipe::where('title', 'LIKE', "%$request->search%")
->orWhere('slug', 'LIKE', "%$request->search%")
->orderBy('updated_at')->paginate();
return new UriResource(
collect($breeders->all())
->merge($recipes->all())
);
}
/**
* Get Model title input text uri (from DB value)
*
* @param \App\Http\Requests\Bo\Ranges\RangeModelTitleRequest $request
* @return \Illuminate\Http\Response
*/
public function modelTitle(RangeModelTitleRequest $request)
{
$title = '';
switch ($request->model) {
case Breeder::class:
/** @var \App\Models\Breeder */
$breeder = Breeder::whereId($request->id)->first();
$title = \trim(optional($breeder)->lastname . ' ' . optional($breeder)->firstname);
break;
case Recipe::class:
$title = Recipe::whereId($request->id)->first()->title ?? '';
break;
}
return \response()->json($title);
}
}

View file

@ -4,20 +4,19 @@ namespace App\Http\Controllers\Bo;
use App\Enums\Sliders\SliderType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Bo\ChangeOrderRequest;
use App\Http\Requests\Bo\Sliders\StoreSliderRequest;
use App\Http\Requests\Bo\Sliders\UpdateSliderRequest;
use App\Models\Slider;
use App\Traits\UpdateModelStatus;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use App\Traits\Controllers\UpdateModelStatus;
use App\Traits\Models\ChangesModelOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\Exceptions\EnumNotRoutableException;
class SliderController extends Controller
{
use ChangesModelOrder;
use UpdateModelStatus;
/**
@ -145,43 +144,4 @@ class SliderController extends Controller
}
return back();
}
/**
* Change Item Order
*
* @param \App\Http\Requests\Bo\ChangeOrderRequest $request
* @param \App\Models\Slider $slider
* @return \Illuminate\Http\RedirectResponse
*/
public function changeOrder(ChangeOrderRequest $request, Slider $slider)
{
try {
DB::transaction(function () use ($request, $slider) {
$tmp = Slider::query();
if ($request->action === 'up') {
// * Push Item upward
$tmp->where('order', '<', $slider->order)
->orderBy('order', 'DESC');
} else {
// * Push Item downward
$tmp->where('order', '>', $slider->order)
->orderBy('order', 'ASC');
}
$tmp->whereType('type', $slider->type);
$tmp = $tmp->firstOrFail();
$newOrder = $tmp->order;
$tmp->order = $slider->order;
$slider->order = $newOrder;
if (!$slider->saveOrFail() or !$tmp->saveOrFail()) {
throw new \ErrorException("Error Processing Request", 1);
}
});
} catch (\ErrorException | ModelNotFoundException $e) {
Session::flash('error', trans('Erreur lors de la modification de l\'ordre :message', [
'message' => $e->getMessage()
]));
} //end try
return redirect()->back();
}
}

View file

@ -74,6 +74,23 @@ class TagController extends Controller
});
}
/**
* Store a newly created resource in storage.
*
* @param \App\Http\Requests\Bo\Tags\StoreTagRequest $request
* @return \Illuminate\Http\Response
*/
public function jsonStore(StoreTagRequest $request)
{
return DB::transaction(function () use ($request) {
/** @var \App\Models\Tag */
$tag = Tag::where('slug', Str::slug($request->name))->firstOrNew();
$tag->fill($request->validated());
$saved = $tag->saveOrFail();
return \response()->json($saved ? $tag->toArray() : [], $saved ? 200 : 500);
});
}
/**
* Show the form for editing the specified resource.
*

View file

@ -30,6 +30,7 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
'htmlsanitize',
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
@ -58,6 +59,6 @@ class Kernel extends HttpKernel
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'xss' => \App\Http\Middleware\XssSanitizer::class,
'htmlsanitize' => \App\Http\Middleware\HtmlSanitizer::class,
];
}

View file

@ -31,7 +31,7 @@ class Authenticate extends Middleware
}
}
}
return route('login');
return collect($logins)->first() ?? '/';
}
abort(401);
}

View file

@ -5,7 +5,7 @@ namespace App\Http\Middleware;
use App\Lib\ToolboxHelper;
use Illuminate\Http\Request;
class XssSanitizer
class HtmlSanitizer
{
/**
* Handle an incoming request.
@ -38,7 +38,6 @@ class XssSanitizer
}
} else {
$input = strip_tags($input);
$input = \filter_var($input, FILTER_SANITIZE_STRING);
}
});
$request->merge($inputs);

View file

@ -44,6 +44,7 @@ class SecureHeaders
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains;');
$cspPolicies = [
"default-src 'none';",
"manifest-src 'self';",
// * CKEDITOR Specific https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/csp.html
"connect-src 'self' blob:;",
"img-src * data: blob:;",

View file

@ -3,6 +3,7 @@
namespace App\Http\Requests\Bo\News;
use App\Enums\PublishedStatus;
use App\Traits\Requests\HasPicture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\Rules\EnumIsValidRule;
@ -12,6 +13,8 @@ use Kwaadpepper\Enum\Rules\EnumIsValidRule;
*/
class StoreNewsRequest extends FormRequest
{
use HasPicture;
/**
* Determine if the user is authorized to make this request.
*
@ -32,6 +35,7 @@ class StoreNewsRequest extends FormRequest
$this->merge([
'slug' => Str::slug($this->title),
]);
$this->mergePicture();
}
/**
@ -41,13 +45,12 @@ class StoreNewsRequest extends FormRequest
*/
public function rules()
{
return [
return \array_merge([
'title' => 'required|min:6|max:255',
'slug' => 'required|max:255',
'seo_title' => 'required|min:53|max:60',
'seo_title' => 'required|min:45|max:70',
'seo_description' => 'required|min:50|max:160',
'content' => 'required|string|min:15',
'picture' => 'sometimes|image|dimensions:width=300,height=300|mimes:png,jpg,jpeg,gif',
'picture_alt' => 'required_with:picture|nullable|string|min:6|max:255',
'picture_title' => 'required_with:picture|nullable|string|min:6|max:255',
'published' => ['required', new EnumIsValidRule(PublishedStatus::class)],
@ -55,7 +58,7 @@ class StoreNewsRequest extends FormRequest
'tags.*' => 'required|array',
'tags.*.id' => 'required|numeric|exists:tags,id|distinct',
'tags.*.name' => 'required|string',
];
], $this->pictureRules(300, 300, 300, 300));
}
/**

View file

@ -0,0 +1,141 @@
<?php
namespace App\Http\Requests\Bo\Products;
use App\Enums\Products\ProductLabelEnum;
use App\Traits\Requests\HasPicture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\Rules\EnumIsValidRule;
/**
* Unicity for fields is handled by model events
*/
class StoreProductRequest extends FormRequest
{
use HasPicture;
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth('backend')->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
{
$this->merge([
'slug' => Str::slug($this->title),
'status' => $this->status ? true : false,
'show_ingredients' => $this->show_ingredients ? true : false,
'show_preparation_advices' => $this->show_preparation_advices ? true : false,
'show_sales_pitch' => $this->show_sales_pitch ? true : false,
'show_allergen_list' => $this->show_allergen_list ? true : false,
'show_nutrionnal_values' => $this->show_nutrionnal_values ? true : false,
'show_portions' => $this->show_portions ? true : false,
'labels' => $this->labels ?? []
]);
$this->mergePicture();
$this->mergePicture('picture_thumb');
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return \array_merge(
[
'title' => 'required|min:6|max:255',
'slug' => 'required|max:255',
'seo_title' => 'required|min:45|max:70',
'seo_description' => 'required|min:50|max:160',
'description' => 'required|string|min:15',
'picture_alt' => 'required_with:picture|nullable|string|min:6|max:255',
'picture_title' => 'required_with:picture|nullable|string|min:6|max:255',
'picture_thumb_alt' => 'required_with:picture_thumb|nullable|string|min:6|max:255',
'picture_thumb_title' => 'required_with:picture_thumb|nullable|string|min:6|max:255',
'publish_at' => 'nullable|date_format:Y-m-d\TH:i',
'unpublish_at' => 'nullable|date_format:Y-m-d\TH:i',
'status' => 'required|boolean',
'range_id' => 'required|exists:ranges,id',
'ingredients' => 'required|string|min:15',
'preparation_advices' => 'required|string|min:15',
'sales_pitch' => 'required|string|min:15',
'allergen_list' => 'required|string|min:15',
'number_portion' => 'required|numeric|min:0',
'show_ingredients' => 'required|boolean',
'show_preparation_advices' => 'required|boolean',
'show_sales_pitch' => 'required|boolean',
'show_allergen_list' => 'required|boolean',
'show_nutrionnal_values' => 'required|boolean',
'show_portions' => 'required|boolean',
'labels' => 'sometimes|array',
'labels.*' => 'sometimes|array',
'labels.*.value' => ['required', new EnumIsValidRule(ProductLabelEnum::class)],
'weight' => 'required|numeric',
'energy' => 'required|numeric',
'energy_kj' => 'required|numeric',
'protein' => 'required|numeric',
'carbohydrates' => 'required|numeric',
'sugar' => 'required|numeric',
'fat' => 'required|numeric',
'saturated_fatty_acids' => 'required|numeric',
'dietary_fiber' => 'required|numeric',
'salt' => 'required|numeric',
],
$this->pictureRules('picture', 700, 700, 700, 700),
$this->pictureRules('picture_thumb', 300, 300, 300, 300)
);
}
/**
* Get custom attributes for validator errors.
*
* @return array
*/
public function attributes(): array
{
return [
'title' => trans('title'),
'slug' => trans('title'),
'seo_title' => trans('title') . ' seo',
'seo_description' => trans('description') . ' seo',
'description' => trans('description'),
'picture' => trans('illustration'),
'picture_alt' => trans('Texte alternatif de l\'illustration'),
'picture_title' => trans('Titre de l\'illustration'),
'picture_thumb' => trans('vignette'),
'picture_thumb_alt' => trans('Texte alternatif de la vignette'),
'picture_thumb_title' => trans('Titre de la vignette'),
'status' => trans('publié ?'),
'publish_at' => trans('date de publication'),
'unpublish_at' => trans('date de dépublication'),
'nutritional' => trans('valeurs nutritionelles'),
'nutritional.energy' => trans('énergie'),
'nutritional.energy_kj' => trans('énergie kj'),
'nutritional.protein' => trans('protéines'),
'nutritional.carbohydrates' => trans('glucides'),
'nutritional.sugar' => trans('sucres'),
'nutritional.fat' => trans('matière grasse'),
'nutritional.saturated_fatty_acids' => trans('acides gras saturés'),
'nutritional.dietary_fiber' => trans('fibres alimentaires'),
'nutritional.salt' => trans('sel'),
];
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Requests\Bo\Products;
/**
* Unicity for fields is handled by model events
*/
class UpdateProductRequest extends StoreProductRequest
{
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Bo\Ranges;
use App\Models\Breeder;
use App\Models\Recipe;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Validator;
class RangeModelTitleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth()->check() and $this->json();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'id' => 'required|numeric',
'model' => [
\sprintf('in:%s,%s', Breeder::class, Recipe::class),
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed,Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed
function ($attribute, $value, $fail) {
$table = (new $value())->getTable();
Validator::validate(['model' => $this->id], [
'model' => "required|exists:{$table},id",
]);
}
]
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Bo\Ranges;
use Illuminate\Foundation\Http\FormRequest;
class RangeSearchUriRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth()->check() and $this->json();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'search' => 'required|string',
'page' => 'sometimes|numeric',
];
}
}

View file

@ -0,0 +1,128 @@
<?php
namespace App\Http\Requests\Bo\Ranges;
use App\Enums\Ranges\RangeCategoryEnum;
use App\Models\Breeder;
use App\Models\Recipe;
use App\Traits\Requests\HasPicture;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\Rules\EnumIsValidRule;
/**
* Unicity for fields is handled by model events
*/
class StoreRangeRequest extends FormRequest
{
use HasPicture;
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth('backend')->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
{
$this->merge([
'slug' => Str::slug($this->title),
'status' => $this->status ? true : false
]);
$this->mergePicture();
$this->mergePicture('picture_thumb');
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return \array_merge(
[
'title' => 'required|min:6|max:255',
'slug' => 'required|max:255',
'category' => ['required', new EnumIsValidRule(RangeCategoryEnum::class)],
'seo_title' => 'required|min:45|max:70',
'seo_description' => 'required|min:50|max:160',
'description' => 'required|string|min:15',
'picture_alt' => 'required_with:picture|nullable|string|min:6|max:255',
'picture_title' => 'required_with:picture|nullable|string|min:6|max:255',
'picture_thumb_alt' => 'required_with:picture_thumb|nullable|string|min:6|max:255',
'picture_thumb_title' => 'required_with:picture_thumb|nullable|string|min:6|max:255',
'publish_at' => 'nullable|date_format:Y-m-d\TH:i',
'unpublish_at' => 'nullable|date_format:Y-m-d\TH:i',
'status' => 'required|boolean',
'link1' => self::getUrlValidationRules(),
'link2' => self::getUrlValidationRules(),
],
$this->pictureRules('picture', 700, 700, 700, 700),
$this->pictureRules('picture_thumb', 300, 300, 300, 300)
);
}
/**
* Get custom attributes for validator errors.
*
* @return array
*/
public function attributes(): array
{
return [
'title' => trans('title'),
'slug' => trans('title'),
'seo_title' => trans('title') . ' seo',
'seo_description' => trans('description') . ' seo',
'description' => trans('description'),
'picture' => trans('illustration'),
'picture_alt' => trans('Texte alternatif de l\'illustration'),
'picture_title' => trans('Titre de l\'illustration'),
'picture_thumb' => trans('vignette'),
'picture_thumb_alt' => trans('Texte alternatif de la vignette'),
'picture_thumb_title' => trans('Titre de la vignette'),
'status' => trans('publié ?'),
'publish_at' => trans('date de publication'),
'unpublish_at' => trans('date de dépublication'),
'link1' => trans('lien 1'),
'link2' => trans('lien 2'),
];
}
/**
* Url validation rules
* Validates an internal url format
*
* @return array
*/
public static function getUrlValidationRules(): array
{
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed
return ['nullable', function ($attribute, $value, $fail) {
$params = collect(\explode('#', \ltrim(\rtrim($value, '#'), '#')));
if (
!\in_array(
$params->get(0),
[Breeder::class, Recipe::class]
)
) {
$fail(trans('Invalid internal url'));
return;
}
// Check if model id exists. Param 0 is class, 1 is id.
if (!\forward_static_call([$params->get(0), 'find'], $params->get(1))) {
$fail(trans("$attribute n\'est plus un lien valide."));
}
}];
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Requests\Bo\Ranges;
/**
* Unicity for fields is handled by model events
*/
class UpdateRangeRequest extends StoreRangeRequest
{
}

View file

@ -2,12 +2,16 @@
namespace App\Http\Requests\Bo\Sliders;
use App\Enums\Sliders\SliderPosition;
use App\Enums\Sliders\SliderElementPosition;
use App\Enums\Sliders\SliderType;
use App\Traits\Requests\HasPicture;
use Illuminate\Foundation\Http\FormRequest;
use Kwaadpepper\Enum\Rules\EnumIsValidRule;
class StoreSliderRequest extends FormRequest
{
use HasPicture;
/**
* Determine if the user is authorized to make this request.
*
@ -31,6 +35,7 @@ class StoreSliderRequest extends FormRequest
'publish_at' => $this->publish_at ? \str_replace('T', ' ', $this->publish_at) : null,
'unpublish_at' => $this->unpublish_at ? \str_replace('T', ' ', $this->unpublish_at) : null,
]);
$this->mergePicture();
}
/**
@ -40,21 +45,31 @@ class StoreSliderRequest extends FormRequest
*/
public function rules()
{
return [
$rules = [
'title' => 'required|min:3|max:255',
'title_color' => 'required|color_hex',
'title_color_bg' => 'required|color_hex',
'position' => ['required', new EnumIsValidRule(SliderPosition::class)],
'picture' => 'required|image|dimensions:width=1400,height=630|mimes:png,jpg,jpeg,gif',
'title_color' => 'nullable|color_rgba',
'title_color_bg' => 'nullable|color_rgba',
'subtitle' => 'required|min:3|max:255',
'subtitle_color' => 'nullable|color_rgba',
'subtitle_color_bg' => 'nullable|color_rgba',
'text_position_desktop' => ['required', new EnumIsValidRule(SliderElementPosition::class)],
'picture_position_mobile' => ['required', new EnumIsValidRule(SliderElementPosition::class)],
'link' => 'required|url',
'link_title' => 'required|min:3|max:255',
'link_color' => 'required|color_hex',
'link_color_bg' => 'required|color_hex',
'link_color' => 'nullable|color_rgba',
'link_color_hover' => 'nullable|color_rgba',
'link_color_bg' => 'nullable|color_rgba',
'link_color_bg_hover' => 'nullable|color_rgba',
'link_new_tab' => 'required|boolean',
'status' => 'required|boolean',
'publish_at' => 'nullable|date_format:Y-m-d H:i',
'unpublish_at' => 'nullable|date_format:Y-m-d H:i',
];
if (!$this->slider) {
$rules['type'] = ['required', new EnumIsValidRule(SliderType::class)];
}
return \array_merge($rules, $this->pictureRules(1400, 630, 1400, 630));
}
/**
@ -68,13 +83,15 @@ class StoreSliderRequest extends FormRequest
'title' => trans('title'),
'title_color' => trans('couleur de titre'),
'title_color_bg' => trans('couleur de fond du titre'),
'position' => trans('position de centrage de l\'image'),
'picture_position_mobile' => trans('position de centrage de l\'image en mobile'),
'text_position_desktop' => trans('position de centrage du texte en desktop'),
'link' => trans('lien'),
'link_title' => trans('titre du lien'),
'link_color' => trans('couleur du lien'),
'link_color_bg' => trans('couleur de fond du lien'),
'link_new_tab' => trans('nouvel onglet'),
'status' => trans('publication'),
'picture' => trans('image'),
];
}
}

View file

@ -11,8 +11,8 @@ class UpdateSliderRequest extends StoreSliderRequest
*/
public function rules()
{
return \array_merge(parent::rules(), [
'picture' => 'sometimes|image|dimensions:width=1400,height=630|mimes:png,jpg,jpeg,gif'
]);
$rules = parent::rules();
unset($rules['type']);
return $rules;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use App\Models\Breeder;
use Illuminate\Http\Resources\Json\JsonResource;
class BreederUriResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param mixed $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function toArray($request)
{
/** @var \App\Models\Breeder */
$model = $this->resource;
return [
'class' => Breeder::class,
'label' => \trim("{$model->lastname} {$model->firstname}"),
'value' => $model->id
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use App\Models\Recipe;
use Illuminate\Http\Resources\Json\JsonResource;
class RecipeUriResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param mixed $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function toArray($request)
{
/** @var \App\Models\Recipe */
$model = $this->resource;
return [
'class' => Recipe::class,
'label' => \trim("{$model->title}"),
'value' => $model->id
];
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Resources;
use App\Models\Breeder;
use App\Models\Recipe;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UriResource extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param mixed $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function toArray($request)
{
/** @var \Illuminate\Support\Collection $collection */
$collection = $this->resource;
return $collection->map(function (Model $model) {
switch (\get_class($model)) {
case Breeder::class:
return new BreederUriResource($model);
case Recipe::class:
return new RecipeUriResource($model);
default:
throw new \RuntimeException(sprintf(
'Unhandled model %s',
\get_class($model)
));
}
});
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Jobs;
use App\Models\Product;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Update published and visibility attributes reading their
* publish_at and unpublish_at attributes.
*/
class ProductStatusUpdate
{
/**
* Called when the job will be executed.
*
* @return void
*/
public function handle()
{
$productsToPublish = Product::where('publish_at', '<=', Carbon::now()->toDateTime())
->whereNotNull('publish_at')
->whereStatus(false)->get();
Log::debug(__CLASS__ . " products to publish " . $productsToPublish->count());
foreach ($productsToPublish as $product) {
$product->publish_at = null;
$product->status = true;
$product->saveOrFail();
}
$productsToUnPublish = Product::where('unpublish_at', '<=', Carbon::now()->toDateTime())
->whereNotNull('unpublish_at')
->whereStatus(true)->get();
Log::debug(__CLASS__ . " products to unpublish " . $productsToPublish->count());
foreach ($productsToUnPublish as $product) {
$product->unpublish_at = null;
$product->status = false;
$product->saveOrFail();
}
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Jobs;
use App\Models\Range;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Update published and visibility attributes reading their
* publish_at and unpublish_at attributes.
*/
class RangesStatusUpdate
{
/**
* Called when the job will be executed.
*
* @return void
*/
public function handle()
{
$rangesToPublish = Range::where('publish_at', '<=', Carbon::now()->toDateTime())
->whereNotNull('publish_at')
->whereStatus(false)->get();
Log::debug(__CLASS__ . " ranges to publish " . $rangesToPublish->count());
foreach ($rangesToPublish as $range) {
$range->publish_at = null;
$range->status = true;
$range->saveOrFail();
}
$rangesToUnPublish = Range::where('unpublish_at', '<=', Carbon::now()->toDateTime())
->whereNotNull('unpublish_at')
->whereStatus(true)->get();
Log::debug(__CLASS__ . " ranges to unpublish " . $rangesToPublish->count());
foreach ($rangesToUnPublish as $range) {
$range->unpublish_at = null;
$range->status = false;
$range->saveOrFail();
}
}
}

View file

@ -192,6 +192,50 @@ class ToolboxHelper
}
}
/**
* Remove pictures in ckeditor
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @return void
*/
public static function removeImagesOnCkEditor(Model $model, string $attribute)
{
// * Remove inline uploaded images
collect(ToolboxHelper::getCkEditorLocalUploadedFiles($model->getOriginal($attribute)))
->map(function (string $originalPicturePath) {
// * If a picture is not used anymore
if (File::exists(\public_path($originalPicturePath))) {
File::delete(\public_path($originalPicturePath));
}
});
}
/**
* Remove unused pictures in ckeditor
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @return void
*/
public static function removeUnusedImagesOnCkEditor(Model $model, string $attribute)
{
// * Remove unused inline uploaded images
if ($model->{$attribute} != $model->getOriginal($attribute)) {
$updatedPicturesPath = collect(
ToolboxHelper::getCkEditorLocalUploadedFiles($model->{$attribute})
);
collect(ToolboxHelper::getCkEditorLocalUploadedFiles($model->getOriginal($attribute)))
->map(function (string $originalPicturePath) use ($updatedPicturesPath) {
// * If a picture is not used anymore
if (!$updatedPicturesPath->contains($originalPicturePath)) {
if (File::exists(\public_path($originalPicturePath))) {
File::delete(\public_path($originalPicturePath));
}
}
});
}
}
/**
* Store an image of different types
*
@ -205,10 +249,7 @@ class ToolboxHelper
$table = $model->getTable();
if (\is_object($image) and $image instanceof \Illuminate\Http\UploadedFile) {
/** @var \Illuminate\Http\UploadedFile $image */
$newPath = self::prepareMoveFile($table, \filter_var(
\strip_tags(\urldecode($image->getClientOriginalName())),
\FILTER_SANITIZE_STRING
));
$newPath = self::prepareMoveFile($table, $image->getClientOriginalName());
$newPath = str_replace(storage_path('app'), '', $newPath);
$image->storePubliclyAs(
pathinfo($newPath, \PATHINFO_DIRNAME),
@ -241,6 +282,15 @@ class ToolboxHelper
*/
private static function prepareMoveFile(string $table, string $filename): string
{
// * Security filter + beautifier
$filename = self::sanitizeFileName($filename);
// * Security filter
$filename = \filter_var($filename, \FILTER_SANITIZE_STRING);
// * Slugify filename
$ext = \pathinfo($filename, \PATHINFO_EXTENSION);
$filename = Str::slug(\pathinfo($filename, \PATHINFO_FILENAME)) .
(\strlen($ext) ? ".{$ext}" : '');
$pathFolder = \storage_path(sprintf('app/public/images/%s', $table));
if (!File::exists($pathFolder)) {
File::makeDirectory($pathFolder, 0755, true);
@ -371,4 +421,79 @@ class ToolboxHelper
->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class)
->name('cspReportUri');
}
/**
* Sanitize File name for uploaded files
*
* @param string $filename The original uploaded client file name.
* @param boolean $beautify Beautify the filename.
* @return string The sanitized file name.
*/
public static function sanitizeFileName(string $filename, bool $beautify = true): string
{
// * Sanitize filename
// phpcs:disable Generic.Files.LineLength.TooLong
$filename = preg_replace(
'~
[<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
[\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
[\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN
[#\[\]@!$&\'()+,;=]| # URI reserved https://tools.ietf.org/html/rfc3986#section-2.2
[{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt
~x',
'-',
$filename
);
// phpcs:enable
// * Avoids '.' '..' or '.hiddenFiles' .
$filename = Str::of($filename)->ltrim('.-');
// * Optional beautification
if ($beautify) {
$filename = static::beautifyFilename($filename);
}
// * Maximize filename length to 255 bytes http://serverfault.com/a/9548/44086
/** @var string */
$extension = pathinfo($filename, \PATHINFO_EXTENSION);
/** @var string */
$filename = pathinfo($filename, \PATHINFO_FILENAME);
$encoding = mb_detect_encoding($filename);
$encoding = $encoding ? $encoding : null;
return \mb_strcut(
$filename,
0,
255 - (!empty($extension) ? strlen($extension) + 1 : 0),
$encoding
) . (!empty($extension) ? ".$extension" : '');
}
/**
* Beautify a filename
*
* @param string $filename
* @return string
*/
private static function beautifyFilename(string $filename): string
{
// Reduce consecutive characters.
$filename = preg_replace([
// * "file name.zip" becomes 'file-name.zip'
'/ +/',
// * "file___name.zip" becomes 'file-name.zip'
'/_+/',
// * "file---name.zip" becomes 'file-name.zip'
'/-+/'
], '-', $filename);
$filename = preg_replace([
// * "file--.--.-.--name.zip" becomes "file.name.zip"
'/-*\.-*/',
// * "file...name..zip" becomes "file.name.zip"
'/\.{2,}/'
], '.', $filename);
// Lowercase for windows/unix interoperability http://support.microsoft.com/kb/100625 .
$filename = mb_strtolower($filename, mb_detect_encoding($filename));
// * ".file-name.-" becomes "file-name"
$filename = trim($filename, '.-');
return $filename;
}
}

11
app/Models/Breeder.php Normal file
View file

@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Breeder extends Model
{
use HasFactory;
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class IncoNutritionalValue extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'weight',
'energy',
'energy_kj',
'protein',
'carbohydrates',
'sugar',
'fat',
'saturated_fatty_acids',
'dietary_fiber',
'salt',
];
// * METHODS
/**
* Inherited from LG, will use for arcadia stream inbound
*
* @param array $xmlInco
* @return array
*/
public static function convertTranslateXmlInco(array $xmlInco): array
{
$cvt = [
'' => 'weight',
'val_energ_kcal_r' => 'energy',
'val_energ_kj_r' => 'energy_kj',
'proteine_r' => 'protein',
'sucre_r' => 'sugar',
'mat_grass_r' => 'fat',
'fib_alim_r' => 'dietary_fiber',
'glucides_r' => 'carbohydrates',
'ags_r' => 'saturated_fatty_acids',
'sel_r' => 'salt'
];
$o = [];
foreach ($cvt as $k => $t) {
$o[$t] = $xmlInco[$k] ?? null;
}
return $o;
}
// * RELATIONSHIPS
/**
* BelongsTo product relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function product()
{
return $this->belongsTo(Product::class);
}
}

View file

@ -2,12 +2,10 @@
namespace App\Models;
use App\Casts\NiceDate;
use App\Enums\PublishedStatus;
use App\Lib\ToolboxHelper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\Traits\CastsEnums;
@ -86,27 +84,14 @@ class News extends Model
}
});
static::updated(function (self $model) {
// * Remove unused inline uploaded images
if ($model->content != $model->getOriginal('content')) {
$updatedPicturesPath = collect(
ToolboxHelper::getCkEditorLocalUploadedFiles($model->content)
);
collect(ToolboxHelper::getCkEditorLocalUploadedFiles($model->getOriginal('content')))
->map(function (string $originalPicturePath) use ($updatedPicturesPath) {
// * If a picture is not used anymore
if (!$updatedPicturesPath->contains($originalPicturePath)) {
if (File::exists(\public_path($originalPicturePath))) {
File::delete(\public_path($originalPicturePath));
}
}
});
}
ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'content');
if ($model->picture != $model->getOriginal('picture')) {
$model->picture = ToolboxHelper::removeOldImage($model, 'picture');
}
});
static::deleting(function (self $model) {
ToolboxHelper::removeImage($model, 'picture');
ToolboxHelper::removeImagesOnCkEditor($model, 'content');
});
}
@ -148,14 +133,4 @@ class News extends Model
ToolboxHelper::assertFieldIsUnique($table, 'title', $title, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($title), $id);
}
/**
* updated_at_date getter
*
* @return string
*/
public function getUpdatedAtDateAttribute(): string
{
return (new NiceDate())->get($this, 'published_at', $this->published_at, []);
}
}

204
app/Models/Product.php Normal file
View file

@ -0,0 +1,204 @@
<?php
namespace App\Models;
use App\Enums\Products\ProductLabelEnum;
use App\Lib\ToolboxHelper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;
class Product extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'slug',
'seo_title',
'seo_description',
'description',
'picture',
'picture_title',
'picture_alt',
'picture_thumb',
'picture_thumb_title',
'picture_thumb_alt',
'labels',
'ingredients',
'preparation_advices',
'sales_pitch',
'allergen_list',
'number_portion',
'status',
'publish_at',
'unpublish_at',
'range_id',
'show_ingredients',
'show_preparation_advices',
'show_sales_pitch',
'show_allergen_list',
'show_nutrionnal_values',
'show_portions'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'labels' => 'array',
'status' => 'bool',
'show_ingredients' => 'bool',
'show_preparation_advices' => 'bool',
'show_sales_pitch' => 'bool',
'show_allergen_list' => 'bool',
'show_nutrionnal_values' => 'bool',
'publish_at' => 'datetime',
'unpublish_at' => 'datetime'
];
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
static::creating(function (self $model) {
static::assertTitleIsUnique($model->title);
self::validateLabels($model->labels);
$model->slug = Str::slug($model->title);
ToolboxHelper::assertNoInternalBoUrls('description', $model->description);
ToolboxHelper::assertNoInternalBoUrls('ingredients', $model->ingredients);
ToolboxHelper::assertNoInternalBoUrls('preparation_advices', $model->preparation_advices);
ToolboxHelper::assertNoInternalBoUrls('sales_pitch', $model->sales_pitch);
ToolboxHelper::assertNoInternalBoUrls('allergen_list', $model->allergen_list);
$model->slug = Str::slug($model->title);
$model->description = clean($model->description);
$model->ingredients = clean($model->ingredients);
$model->preparation_advices = clean($model->preparation_advices);
$model->sales_pitch = clean($model->sales_pitch);
$model->allergen_list = clean($model->allergen_list);
$model->picture = ToolboxHelper::storeImage($model, $model->picture);
$model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb);
if (!$model->status) {
$model->publish_at = null;
} else {
$model->unpublish_at = null;
}
});
static::updating(function (self $model) {
static::assertTitleIsUnique($model->title, $model->id);
self::validateLabels($model->labels);
$model->slug = Str::slug($model->title);
ToolboxHelper::assertNoInternalBoUrls('description', $model->description);
ToolboxHelper::assertNoInternalBoUrls('ingredients', $model->ingredients);
ToolboxHelper::assertNoInternalBoUrls('preparation_advices', $model->preparation_advices);
ToolboxHelper::assertNoInternalBoUrls('sales_pitch', $model->sales_pitch);
ToolboxHelper::assertNoInternalBoUrls('allergen_list', $model->allergen_list);
$model->slug = Str::slug($model->title);
$model->description = clean($model->description);
$model->ingredients = clean($model->ingredients);
$model->preparation_advices = clean($model->preparation_advices);
$model->sales_pitch = clean($model->sales_pitch);
$model->allergen_list = clean($model->allergen_list);
// * Store new picture if changed
if ($model->picture != $model->getOriginal('picture')) {
$model->picture = ToolboxHelper::storeImage($model, $model->picture);
}
// * Store new picture_thumb if changed
if ($model->picture_thumb != $model->getOriginal('picture_thumb')) {
$model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb);
}
});
static::updated(function (self $model) {
ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'description');
ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'ingredients');
ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'preparation_advices');
ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'sales_pitch');
ToolboxHelper::removeUnusedImagesOnCkEditor($model, 'allergen_list');
if ($model->picture != $model->getOriginal('picture')) {
$model->picture = ToolboxHelper::removeOldImage($model, 'picture');
}
});
static::deleting(function (self $model) {
ToolboxHelper::removeImage($model, 'picture');
ToolboxHelper::removeImage($model, 'picture_thumb');
ToolboxHelper::removeImagesOnCkEditor($model, 'description');
ToolboxHelper::removeImagesOnCkEditor($model, 'ingredients');
ToolboxHelper::removeImagesOnCkEditor($model, 'preparation_advices');
ToolboxHelper::removeImagesOnCkEditor($model, 'sales_pitch');
ToolboxHelper::removeImagesOnCkEditor($model, 'allergen_list');
});
}
// * RELATIONSHIPS
/**
* HasOne incoNutritionalValue relation
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function incoNutritionalValues()
{
return $this->hasOne(IncoNutritionalValue::class);
}
/**
* BelongsTo range relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function range()
{
return $this->belongsTo(Range::class);
}
// * METHODS
/**
* Asserts using validation that the title is unique
*
* @param mixed $title
* @param integer|null $id
* @return void
* @throws \Illuminate\Validation\ValidationException If an actu title already exists.
*/
private static function assertTitleIsUnique($title, ?int $id = null)
{
$table = (new self())->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'title', $title, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($title), $id);
}
/**
*
* @param array $labels
* @return void
* @throws \Illuminate\Validation\ValidationException If a label is invalid.
*/
private static function validateLabels(array $labels)
{
collect($labels)->map(function ($label) {
if (!\is_object($label) or !($label instanceof ProductLabelEnum)) {
if (
!\is_array($label) or
!\array_key_exists('value', $label) or
!\in_array($label['value'], ProductLabelEnum::toValues())
) {
throw ValidationException::withMessages([
'labels' => 'Invalid label given'
]);
}
}
});
}
}

180
app/Models/Range.php Normal file
View file

@ -0,0 +1,180 @@
<?php
namespace App\Models;
use App\Enums\Ranges\RangeCategoryEnum;
use App\Lib\ToolboxHelper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Kwaadpepper\Enum\Traits\CastsEnums;
class Range extends Model
{
use CastsEnums;
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'slug',
'seo_title',
'seo_description',
'description',
'category',
'picture',
'picture_title',
'picture_alt',
'picture_thumb',
'picture_thumb_title',
'picture_thumb_alt',
'status',
'publish_at',
'unpublish_at',
'link1',
'link2',
];
/**
* Map attribute names to enum classes.
*
* @var array
*/
protected $enumCasts = [
'category' => RangeCategoryEnum::class
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'status' => 'bool',
'publish_at' => 'datetime',
'unpublish_at' => 'datetime'
];
/**
* Perform any actions required after the model boots.
*
* @return void
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
protected static function booted()
{
// phpcs:enable
static::creating(function (self $model) {
static::assertTitleIsUnique($model->title);
ToolboxHelper::assertNoInternalBoUrls('description', $model->description);
$model->slug = Str::slug($model->title);
$model->description = clean($model->description);
$model->picture = ToolboxHelper::storeImage($model, $model->picture);
$model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb);
if (!$model->status) {
$model->publish_at = null;
} else {
$model->unpublish_at = null;
}
if ($model->link1) {
self::validateLinkAttribute('link1', $model->link1);
}
if ($model->link2) {
self::validateLinkAttribute('link2', $model->link2);
}
});
static::updating(function (self $model) {
static::assertTitleIsUnique($model->title, $model->id);
ToolboxHelper::assertNoInternalBoUrls('description', $model->description);
if ($model->picture != $model->getOriginal('picture')) {
$model->picture = ToolboxHelper::storeImage($model, $model->picture);
}
if ($model->picture_thumb != $model->getOriginal('picture_thumb')) {
$model->picture_thumb = ToolboxHelper::storeImage($model, $model->picture_thumb);
}
// * If status changed
if (\boolval($model->getOriginal('status')) !== $model->status) {
if ($model->status) {
$model->publish_at = null;
Session::flash('info', trans('La gamme est à présent publié'));
} else {
$model->unpublish_at = null;
Session::flash('warning', trans('La gamme est à présent dépublié'));
}
}
if ($model->link1) {
self::validateLinkAttribute('link1', $model->link1);
}
if ($model->link2) {
self::validateLinkAttribute('link2', $model->link2);
}
});
static::updated(function (self $model) {
if ($model->picture != $model->getOriginal('picture')) {
$model->picture = ToolboxHelper::removeOldImage($model, 'picture');
}
if ($model->picture_v != $model->getOriginal('picture_v')) {
$model->picture_v = ToolboxHelper::removeOldImage($model, 'picture_v');
}
});
static::deleting(function (self $model) {
ToolboxHelper::removeImage($model, 'picture');
ToolboxHelper::removeImage($model, 'picture_v');
});
}
// * METHODS
/**
* Asserts using validation that the title is unique
*
* @param mixed $title
* @param integer|null $id
* @return void
* @throws \Illuminate\Validation\ValidationException If an actu title already exists.
*/
private static function assertTitleIsUnique($title, ?int $id = null)
{
$table = (new self())->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'title', $title, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($title), $id);
}
/**
* Validates an internal link
*
* @param string $attribute
* @param string $value
* @return void
* @throws \Illuminate\Validation\ValidationException If the $attribute link is invalid.
*/
public static function validateLinkAttribute(string $attribute, string $value): void
{
// Validate case internal url.
$params = collect(\explode('#', \ltrim(\rtrim($value, '#'), '#')));
if (
!\in_array(
$params->get(0),
[Breeder::class, Recipe::class]
)
) {
throw ValidationException::withMessages([
$attribute => trans('Le lien contient un modèle invalide \':model\'', [
'model' => $params->get(0)
])
]);
}
// Check if model id exists. Param 0 is class, 1 is id.
if (!\forward_static_call([$params->get(0), 'find'], $params->get(1))) {
throw ValidationException::withMessages([
$attribute => trans('Le lien n\'est plus valide.')
]);
}
}
}

45
app/Models/Recipe.php Normal file
View file

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\Lib\ToolboxHelper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Recipe extends Model
{
use HasFactory;
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
static::creating(function (self $model) {
static::assertTitleIsUnique($model->title);
});
static::updating(function (self $model) {
static::assertTitleIsUnique($model->title, $model->id);
});
}
// * METHODS
/**
* Asserts using validation that the title is unique
*
* @param mixed $title
* @param integer|null $id
* @return void
* @throws \Illuminate\Validation\ValidationException If an actu title already exists.
*/
private static function assertTitleIsUnique($title, ?int $id = null)
{
$table = (new self())->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'title', $title, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($title), $id);
}
}

View file

@ -2,9 +2,8 @@
namespace App\Models;
use App\Casts\HtmlColor;
use App\Casts\NiceDate;
use App\Enums\Sliders\SliderPosition;
use App\Casts\RgbaColorNullable;
use App\Enums\Sliders\SliderElementPosition;
use App\Enums\Sliders\SliderType;
use App\Lib\ToolboxHelper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -29,13 +28,15 @@ class Slider extends Model
'subtitle',
'subtitle_color',
'subtitle_color_bg',
'position',
'type',
'text_position_desktop',
'picture_position_mobile',
'picture',
'link',
'link_title',
'link_color',
'link_color_hover',
'link_color_bg',
'link_color_bg_hover',
'link_new_tab',
'status',
'order',
@ -49,7 +50,8 @@ class Slider extends Model
* @var array
*/
protected $enumCasts = [
'position' => SliderPosition::class,
'text_position_desktop' => SliderElementPosition::class,
'picture_position_mobile' => SliderElementPosition::class,
'type' => SliderType::class
];
@ -59,12 +61,14 @@ class Slider extends Model
* @var array
*/
protected $casts = [
'title_color' => HtmlColor::class,
'title_color_bg' => HtmlColor::class,
'subtitle_color' => HtmlColor::class,
'subtitle_color_bg' => HtmlColor::class,
'link_color' => HtmlColor::class,
'link_color_bg' => HtmlColor::class,
'title_color' => RgbaColorNullable::class,
'title_color_bg' => RgbaColorNullable::class,
'subtitle_color' => RgbaColorNullable::class,
'subtitle_color_bg' => RgbaColorNullable::class,
'link_color' => RgbaColorNullable::class,
'link_color_hover' => RgbaColorNullable::class,
'link_color_bg' => RgbaColorNullable::class,
'link_color_bg_hover' => RgbaColorNullable::class,
'link_new_tab' => 'bool',
'status' => 'bool',
'publish_at' => 'datetime',
@ -115,14 +119,4 @@ class Slider extends Model
}
// * METHODS
/**
* updated_at_date getter
*
* @return string
*/
public function getUpdatedAtDateAttribute(): string
{
return (new NiceDate())->get($this, 'updated_at', $this->updated_at, []);
}
}

View file

@ -4,6 +4,8 @@ namespace App\Providers;
use App\Lib\ToolboxHelper;
use App\Models\News;
use App\Models\Product;
use App\Models\Range;
use App\Models\Slider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
@ -43,6 +45,8 @@ class RouteServiceProvider extends ServiceProvider
->group(base_path('routes/web-fo.php'));
});
Route::model('product', Product::class);
Route::model('range', Range::class);
Route::model('slider', Slider::class);
Route::model('news', News::class, function ($value) {
return News::where(function ($q) use ($value) {

View file

@ -1,6 +1,6 @@
<?php
namespace App\Traits;
namespace App\Traits\Controllers;
use Illuminate\Database\Eloquent\Model;
@ -14,7 +14,7 @@ trait UpdateModelStatus
/**
* Change model published
*
* @param Model $model
* @param \Illuminate\Database\Eloquent\Model $model
* @return \Illuminate\Http\RedirectResponse
*/
public function changePublished(Model $model)

View file

@ -1,144 +0,0 @@
<?php
namespace App\Traits;
use Illuminate\Support\Facades\File;
trait HasPicture
{
/**
* Images rules
*
* @return array
*/
public function pictureRule(): array
{
return [
'picture' => [
'required',
function (string $attribute, $value, callable $fail) {
return $this->validatePicture($attribute, $value, $fail);
}
]
];
}
/**
* Images rules
*
* @return array
*/
public function pictureRules(): array
{
return [
'pictures' => 'sometimes|array',
'pictureValidate' => 'required_with:pictures|array',
'pictureValidate.*' => [
'required',
function (string $attribute, $value, callable $fail) {
return $this->validatePicture($attribute, $value, $fail);
}
]
];
}
/**
* Validate picture
*
* @param string $attribute
* @param mixed $value
* @param callback $fail
* @return void
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
*/
private function validatePicture(string $attribute, $value, callable $fail)
{
if (!File::exists($value)) {
$fail(trans(":attribute n'existe pas dans le système de fichier"));
return;
}
if (!\in_array(File::mimeType($value), ['image/jpeg', 'image/jpg', 'image/png'])) {
$fail(trans(":attribute doit être une image de type 'image/jpeg,image/jpg,image/png'"));
}
if (!($dimensions = \getimagesize($value)) or !isset($dimensions[0]) or !isset($dimensions[1])) {
$fail(trans(":attribute impossible de valider les dimensions"));
} else {
$minWith = 400;
$maxWith = 1800;
$minHeight = 300;
$maxHeight = 1800;
$width = intval($dimensions[0]);
$height = intval($dimensions[1]);
if (($width < $minWith) or ($width > $maxWith)) {
$fail(trans(":attribute la largeur doit être comprise entre $minWith et $maxWith"));
}
if (($height < $minHeight) or ($height > $maxHeight)) {
$fail(trans(":attribute la hauteur doit être comprise entre $minHeight et $maxHeight"));
}
}
}
/**
* Merges products pictures fields with request data
*
* @return void
*/
protected function mergePicture(): void
{
$picturePath = $this->picture;
if (is_string($picturePath)) {
$this->merge(['pictureValidate' => []]);
// ! Empty picture.
if ($picturePath === 'false') {
// * Empty picture and exit
$this->merge(['pictures' => []]);
return;
}
$decodedPath = \urldecode($picturePath);
$this->merge([
'pictureValidate' => array_merge(
$this->pictureValidate,
[\public_path(ltrim($decodedPath, '/'))]
)
]);
$changedPicture = $this->picture;
$changedPicture = ltrim($decodedPath, '/');
$this->merge([
'picture' => $changedPicture
]);
} //end if
}
/**
* Merges products pictures fields with request data
*
* @return void
*/
protected function mergePictures(): void
{
$pictures = $this->pictures;
if (is_array($pictures)) {
$this->merge(['pictureValidate' => []]);
foreach ($pictures as $key => $picturePath) {
// ! Empty picture.
if ($picturePath === 'false') {
// * Empty picture and exit
$this->merge(['pictures' => []]);
return;
}
$decodedPath = \urldecode($picturePath);
$this->merge([
'pictureValidate' => array_merge(
$this->pictureValidate,
[\public_path(ltrim($decodedPath, '/'))]
)
]);
$changedPicture = $this->pictures;
$changedPicture[$key] = ltrim($decodedPath, '/');
$this->merge([
'pictures' => $changedPicture
]);
}
} //end if
}
}

View file

@ -1,103 +0,0 @@
<?php
namespace App\Traits;
use App\Enums\InputType;
use App\Lib\ToolboxHelper;
trait MiniCrudModel
{
/**
* Fill the model with an array of attributes.
*
* @param array $attributes
* @return void
* @throws \Illuminate\Database\Eloquent\MassAssignmentException If fill prop is not allowed.
* @phpcs:disable Squiz.Commenting.FunctionComment.EmptyThrows
*/
public function fill(array $attributes)
{
parent::fill($attributes);
if (isset($attributes['data'])) {
$this->setData($attributes['data']);
}
}
/**
* Set a specific prop
*
* @param array $data
* @return void
*/
private function setData(array $data): void
{
$attrs = $this->attributes;
if (isset($attrs['data'])) {
unset($attrs['data']);
}
$data = $this->serializeData($data);
$this->setRawAttributes(\array_merge_recursive(
$attrs,
['data' => $data]
));
}
/**
* Get data attribute
* Cast json_decode to object.
*
* @return object|null
*/
protected function getDataAttribute(): ?object
{
return \json_decode($this->getAttributes()['data'] ?? '[]');
}
/**
* Get data attribute
* Cast json_decode to object.
*
* @param array $data
* @return void
*/
protected function setDataAttribute(array $data): void
{
$this->setData($data);
}
/**
* Serialize properties
*
* @param array $data
* @return string
* @throws \ErrorException If input type is no handled.
* @throws \Illuminate\Validation\ValidationException If url is a bo url.
* @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
private function serializeData(array $data): string
{
foreach ($data as $o => $input) {
switch ($input['inputType']->value ?? $input['inputType']['value']) {
case InputType::image()->value:
$data[$o]['value'] = ToolboxHelper::storeImage($this->getTable(), $input['value']);
break;
case InputType::boolean()->value:
case InputType::text()->value:
case InputType::number()->value:
case InputType::string()->value:
case InputType::videoVimeo()->value:
break;
case InputType::url()->value:
ToolboxHelper::assertNoInternalBoUrls('content', $input['value']);
break;
default:
throw new \ErrorException(sprintf(
'Unknown input Type %s',
$input['inputType']->label ?? $input['inputType']['label']
));
}
}//end foreach
return json_encode($data);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Traits\Models;
use App\Http\Requests\Bo\ChangeOrderRequest;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
trait ChangesModelOrder
{
/**
* Change order
*
* @param \App\Traits\Models\ChangeOrderRequest $request
* @param \Illuminate\Database\Eloquent\Model $model
* @return \Illuminate\Http\RedirectResponse
* @throws \RuntimeException Si le nom du parametre de la route ne correspond pas au model utilisé.
*/
public function changeOrder(ChangeOrderRequest $request, Model $model): RedirectResponse
{
return DB::transaction(function () use ($request, $model) {
/** @var \Illuminate\Database\Eloquent\Model|null */
$tmp = (\get_class($model))::where('order', $request->direction ? '<' : '>', $model->order)
->orderBy('order', $request->direction ? 'DESC' : 'ASC')->first();
if (!$tmp) {
return back()->with('error', trans('L\'ordre n\'a pas pu être modifié'));
}
$newOrder = $tmp->order;
$tmp->order = $model->order;
$model->order = $newOrder;
$tmp->saveOrFail();
$model->saveOrFail();
Session::flash('success', trans('L\'ordre à été modifié'));
return back();
});
}
}

View file

@ -0,0 +1,216 @@
<?php
namespace App\Traits\Requests;
use Illuminate\Support\Facades\File;
trait HasPicture
{
/**
* Minimum width
*
* @var integer
*/
private $minWith = 400;
/**
* Maximum width
*
* @var integer
*/
private $maxWith = 1800;
/**
* Minimum height
*
* @var integer
*/
private $minHeight = 300;
/**
* Maximum height
*
* @var integer
*/
private $maxHeight = 1800;
/**
* Image rules
*
* @param string $name
* @param integer|null $minWith
* @param integer|null $minHeight
* @param integer|null $maxWith
* @param integer|null $maxHeight
* @return array
*/
public function pictureRules(
string $name = 'picture',
?int $minWith = null,
?int $minHeight = null,
?int $maxWith = null,
?int $maxHeight = null
): array {
$sizes = (object)[
'minWith' => $minWith ?? $this->minWith,
'minHeight' => $minHeight ?? $this->minHeight,
'maxWith' => $maxWith ?? $this->maxWith,
'maxHeight' => $maxHeight ?? $this->maxHeight
];
return [
$name => [
'required',
function (string $attribute, $value, callable $fail) use ($sizes) {
return $this->validatePicture($attribute, $value, $fail, $sizes);
}
]
];
}
/**
* Images rules
*
* @param string $name
* @param integer|null $minWith
* @param integer|null $minHeight
* @param integer|null $maxWith
* @param integer|null $maxHeight
* @return array
*/
public function picturesRules(
string $name = 'pictures',
?int $minWith = null,
?int $minHeight = null,
?int $maxWith = null,
?int $maxHeight = null
): array {
$sizes = (object)[
'minWith' => $minWith ?? $this->minWith,
'minHeight' => $minHeight ?? $this->minHeight,
'maxWith' => $maxWith ?? $this->maxWith,
'maxHeight' => $maxHeight ?? $this->maxHeight
];
return [
$name => 'sometimes|array',
"{$name}Validate" => "required_with:{$name}|array",
"{$name}Validate.*" => [
'required',
function (string $attribute, $value, callable $fail) use ($sizes) {
return $this->validatePicture($attribute, $value, $fail, $sizes);
}
]
];
}
/**
* Validate picture
*
* @param string $attribute
* @param mixed $value
* @param callback $fail
* @param object $sizes
* @return void
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
*/
private function validatePicture(string $attribute, $value, callable $fail, object $sizes)
{
if (!File::exists($value)) {
$fail(trans(":attribute n'existe pas dans le système de fichier"));
return;
}
if (!\in_array(File::mimeType($value), ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'])) {
$fail(trans(":attribute doit être une image de type 'image/jpeg,image/jpg,image/png,image/gif'"));
}
if (!($dimensions = \getimagesize($value)) or !isset($dimensions[0]) or !isset($dimensions[1])) {
$fail(trans(":attribute impossible de valider les dimensions"));
} else {
$width = intval($dimensions[0]);
$height = intval($dimensions[1]);
$xMessage = trans(
":attribute la largeur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px"
);
if ($sizes->minWith === $sizes->maxWith) {
$xMessage = trans(":attribute la largeur doit être de {$sizes->minWith}px");
}
$yMessage = trans(
":attribute la hauteur doit être comprise entre {$sizes->minWith}px et {$sizes->maxWith}px"
);
if ($sizes->minHeight === $sizes->maxHeight) {
$yMessage = trans(":attribute la hauteur doit être de {$sizes->minWith}px");
}
if (($width < $sizes->minWith) or ($width > $sizes->maxWith)) {
$fail($xMessage);
}
if (($height < $sizes->minHeight) or ($height > $sizes->maxHeight)) {
$fail($yMessage);
}
} //end if
}
/**
* Merges products pictures fields with request data
*
* @param string $name
* @return void
*/
protected function mergePicture(string $name = 'picture'): void
{
$picturePath = $this->{$name};
if (is_string($picturePath)) {
$this->merge(["{$name}Validate" => []]);
// ! Empty picture.
if ($picturePath === 'false') {
// * Empty picture and exit
$this->merge([$name => []]);
return;
}
$decodedPath = \urldecode($picturePath);
$this->merge([
"{$name}Validate" => array_merge(
$this->{"{$name}Validate"},
[\public_path(ltrim($decodedPath, '/'))]
)
]);
$changedPicture = ltrim($decodedPath, '/');
$this->merge([
$name => $changedPicture
]);
} //end if
}
/**
* Merges products pictures fields with request data
*
* @param string $name
* @return void
*/
protected function mergePictures(string $name = 'pictures'): void
{
$pictures = $this->{$name};
if (is_array($pictures)) {
$this->merge(["{$name}Validate" => []]);
foreach ($pictures as $key => $picturePath) {
// ! Empty picture.
if ($picturePath === 'false') {
// * Empty picture and exit
$this->merge([$name => []]);
return;
}
$decodedPath = \urldecode($picturePath);
$this->merge([
"{$name}Validate" => array_merge(
$this->{"{$name}Validate"},
[\public_path(ltrim($decodedPath, '/'))]
)
]);
$changedPicture = $this->{$name};
$changedPicture[$key] = ltrim($decodedPath, '/');
$this->merge([
$name => $changedPicture
]);
}
} //end if
}
}

View file

@ -2,7 +2,10 @@
"name": "laravel/laravel",
"type": "project",
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"require": {
"php": "^7.3|^8.0",
@ -18,7 +21,6 @@
"laravel/tinker": "^2.5",
"laravel/ui": "^3.4",
"mews/purifier": "^3.3",
"slevomat/coding-standard": "^7.2",
"spatie/laravel-mix-preload": "^1.3"
},
"require-dev": {
@ -27,7 +29,8 @@
"laravel/sail": "^1.0.1",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^5.10",
"phpunit/phpunit": "^9.5.10"
"phpunit/phpunit": "^9.5.10",
"slevomat/coding-standard": "^7.2"
},
"autoload": {
"psr-4": {
@ -47,7 +50,8 @@
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"local-php-security-checker"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""

933
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Breeder;
use Illuminate\Database\Eloquent\Factories\Factory;
class BreederFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Breeder::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'firstname' => $this->faker->firstName(),
'lastname' => $this->faker->lastName(),
];
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Database\Factories;
use App\Models\IncoNutritionalValue;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
class IncoNutritionalValueFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = IncoNutritionalValue::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
"weight" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(231 - 150, 231 + 150),
"energy" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(231 - 150, 231 + 150),
"energy_kj" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(964 - 150, 964 + 150),
"protein" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(9.2 - 3, 9.2 + 15),
"carbohydrates" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(20 - 10, 20 + 25),
"sugar" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(1.5 - 1, 1.5 + 6),
"fat" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(12 - 6, 12 + 6),
"saturated_fatty_acids" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(3.4 - 2, 3.4 + 6),
"dietary_fiber" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(2.9 - 2, 2.9 + 6),
"salt" => $this->faker->boolean(10) ?
null : $this->faker->numberBetween(1.1 - 1, 1.1 + 1.1)
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Database\Factories;
use App\Enums\Products\ProductLabelEnum;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Product::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
$title = $this->faker->unique()->words(\mt_rand(3, 4), true);
return [
'title' => $title,
'slug' => \Illuminate\Support\Str::slug($title),
'seo_title' => $this->faker->words(8, true),
'seo_description' => $this->faker->words(25, true),
'description' => $this->faker->words(25, true),
'picture' => '/assets/images/products/product_fake_700_700.jpg',
'picture_title' => $this->faker->words(8, true),
'picture_alt' => $this->faker->words(8, true),
'picture_thumb' => '/assets/images/products/product_fake_thumb_300_300.jpg',
'picture_thumb_title' => $this->faker->words(8, true),
'picture_thumb_alt' => $this->faker->words(8, true),
'labels' => collect(ProductLabelEnum::toArray())->random(rand(0, 4))->all(),
'ingredients' => $this->faker->words(8, true),
'preparation_advices' => $this->faker->text(\mt_rand(15, 150)),
'sales_pitch' => $this->faker->text(\mt_rand(15, 150)),
'allergen_list' => $this->faker->words(8, true),
'number_portion' => $this->faker->numberBetween(0, 8),
'status' => $this->faker->boolean(75),
'show_ingredients' => $this->faker->boolean(75),
'show_preparation_advices' => $this->faker->boolean(75),
'show_sales_pitch' => $this->faker->boolean(75),
'show_allergen_list' => $this->faker->boolean(75),
'show_nutrionnal_values' => $this->faker->boolean(75),
'show_portions' => $this->faker->boolean(75),
];
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Database\Factories;
use App\Enums\Ranges\RangeCategoryEnum;
use App\Models\Range;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class RangeFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Range::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
$title = $this->faker->unique()->words(rand(6, 12), true);
return [
'title' => $title,
'slug' => Str::slug($title),
'seo_title' => $this->faker->words(8, true),
'seo_description' => $this->faker->words(25, true),
'description' => $this->faker->words(25, true),
'category' => collect(RangeCategoryEnum::toArray())->random(1)->get(0),
'picture' => '/assets/images/ranges/range_fake_700_700.jpg',
'picture_title' => $this->faker->words(8, true),
'picture_alt' => $this->faker->words(8, true),
'picture_thumb' => '/assets/images/ranges/range_fake_thumb_300_300.jpg',
'picture_thumb_title' => $this->faker->words(8, true),
'picture_thumb_alt' => $this->faker->words(8, true),
'status' => $this->faker->boolean(75),
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\Recipe;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class RecipeFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Recipe::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
$title = $this->faker->unique()->words(rand(2, 4), true);
return [
'title' => $title,
'slug' => Str::slug($title)
];
}
}

View file

@ -2,7 +2,7 @@
namespace Database\Factories;
use App\Enums\Sliders\SliderPosition;
use App\Enums\Sliders\SliderElementPosition;
use App\Enums\Sliders\SliderType;
use App\Models\Slider;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -34,7 +34,8 @@ class SliderFactory extends Factory
$subtitle = $this->faker->unique()->words(rand(8, 10), true);
return [
'type' => Collect(SliderType::toArray())->random(),
'position' => Collect(SliderPosition::toArray())->random(),
'text_position_desktop' => Collect(SliderElementPosition::toArray())->random(),
'picture_position_mobile' => Collect(SliderElementPosition::toArray())->random(),
'title' => $title,
'title_color' => $this->faker->hexColor(),
'title_color_bg' => $this->faker->hexColor(),

View file

@ -1,6 +1,6 @@
<?php
use App\Enums\Sliders\SliderPosition;
use App\Enums\Sliders\SliderElementPosition;
use App\Enums\Sliders\SliderType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
@ -20,8 +20,8 @@ class CreateSlidersTable extends Migration
$table->unsignedTinyInteger('type')->default(SliderType::home())
->comment('Enums\\SliderType type de slider');
$table->unsignedTinyInteger('position')->default(SliderPosition::center())
->comment('Enums\\SliderPosition slider centrage du slider en responsive');
$table->unsignedTinyInteger('position')->default(SliderElementPosition::center())
->comment('Enums\\SliderElementPosition slider centrage du slider en responsive');
$table->string('title')->comment('Titre du slider');
$table->unsignedInteger('title_color')->comment('Couleur du titre');

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddHoverColorsOnSliders extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('sliders', function (Blueprint $table) {
$table->unsignedInteger('link_color')->comment('Couleur du lien')->change();
$table->unsignedInteger('link_color_bg')->comment('Couleur de fond du lien')->change();
// Add columns.
$table->unsignedInteger('link_color_hover')->comment('Couleur du lien (au survol)')->after('link_color');
$table->unsignedInteger('link_color_bg_hover')->comment('Couleur de fond du lien (au survol)')
->after('link_color_bg');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('sliders', function (Blueprint $table) {
$table->string('link_color')->change()->comment('Couleur du lien');
$table->string('link_color_bg')->change()->comment('Couleur de fond du lien');
// Add columns.
$table->removeColumn('link_color_hover');
$table->removeColumn('link_color_bg_hover');
});
}
}

View file

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class SetColorsNullableToSliders extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('sliders', function (Blueprint $table) {
$table->unsignedInteger('title_color')->nullable()->comment('Couleur du titre')->change();
$table->unsignedInteger('title_color_bg')->nullable()->comment('Couleur de fond du titre')->change();
$table->unsignedInteger('subtitle_color')->nullable()->comment('Couleur du soustitre')->change();
$table->unsignedInteger('subtitle_color_bg')->nullable()->comment('Couleur de fond du soustitre')->change();
$table->unsignedInteger('link_color')->nullable()->comment('Couleur du lien')->change();
$table->unsignedInteger('link_color_hover')->nullable()
->comment('Couleur de fond du lien (au survol)')->change();
$table->unsignedInteger('link_color_bg')->nullable()->comment('Couleur de fond du lien')->change();
$table->unsignedInteger('link_color_bg_hover')->nullable()
->comment('Couleur de fond du lien (au survol)')->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('sliders', function (Blueprint $table) {
$table->unsignedInteger('title_color')->comment('Couleur du titre')->change();
$table->unsignedInteger('title_color_bg')->comment('Couleur de fond du titre')->change();
$table->unsignedInteger('subtitle_color')->comment('Couleur du soustitre')->change();
$table->unsignedInteger('subtitle_color_bg')->comment('Couleur de fond du soustitre')->change();
$table->unsignedInteger('link_color')->comment('Couleur du lien')->change();
$table->unsignedInteger('link_color_bg')->comment('Couleur de fond du lien')->change();
});
}
}

View file

@ -0,0 +1,38 @@
<?php
use App\Enums\Sliders\SliderElementPosition;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class TextPositionOnSliders extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('sliders', function (Blueprint $table) {
$table->renameColumn('position', 'picture_position_mobile');
$table->unsignedTinyInteger('text_position_desktop')
->after('subtitle_color_bg')
->default(SliderElementPosition::right())
->comment('Enums\\SliderElementPosition postionnement du texte en desktop');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('sliders', function (Blueprint $table) {
$table->renameColumn('picture_position_mobile', 'position');
$table->dropColumn('text_position_desktop');
});
}
}

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateBreedersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('breeders', function (Blueprint $table) {
$table->id();
$table->string('firstname')->comment('The breeder firstname');
$table->string('lastname')->comment('The breeder lastname');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('breeders');
}
}

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateRecipesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('recipes', function (Blueprint $table) {
$table->id();
$table->string('title')->comment('Nom de la recette');
$table->string('slug')->comment('slug de la recette');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('recipes');
}
}

View file

@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateRangesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('ranges', function (Blueprint $table) {
$table->id();
$table->string('title')->comment('Nom de la gamme');
$table->string('slug')->comment('slug de la gamme');
$table->string('seo_title')
->comment('Utilisé pour la balise title de la gamme. De 53 à 60 chars recommandés.');
$table->string('seo_description')
->comment('Utilisé pour la balise méta description de la gamme. De 50 à 160 chars recommandés.');
$table->text('description')->comment('Description de la gamme');
$table->unsignedTinyInteger('category')->comment('Enum Ranges\\RangeCategoryEnum');
$table->string('picture')->nullable()->comment('L\'illustration de la gamme 700x700 px.');
$table->string('picture_title')->nullable()->comment('Attribut title de l\'image.');
$table->string('picture_alt')->nullable()->comment('Attribut alt de l\'image.');
$table->string('picture_thumb')->nullable()->comment('La vignette de la gamme 300x300 px.');
$table->string('picture_thumb_title')->nullable()->comment('Attribut title de l\'image.');
$table->string('picture_thumb_alt')->nullable()->comment('Attribut alt de l\'image.');
$table->boolean('status')->comment('La gamme est visible ou non');
$table->timestamp('publish_at')->nullable()
->comment('The date where the category status state should be set on.');
$table->timestamp('unpublish_at')->nullable()
->comment('The date where the category status state should be set off.');
$table->string('link1')->nullable()
->comment('Category Link 1 #App\Models\Model#ID#, Sould be validated on store and update');
$table->string('link2')->nullable()
->comment('Category Link 2 #App\Models\Model#ID#, Sould be validated on store and update');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('ranges');
}
}

View file

@ -0,0 +1,75 @@
<?php
use App\Models\Range;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateProductsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('title')->comment('Nom du produit');
$table->string('slug')->comment('slug du produit');
$table->string('seo_title')
->comment('Utilisé pour la balise title de la page. De 53 à 60 chars recommandés.');
$table->string('seo_description')
->comment('Utilisé pour la balise méta description de la page. De 50 à 160 chars recommandés.');
$table->text('description')->comment('Description du produit');
$table->string('picture')->nullable()->comment('L\'illustration du produit 700x700 px.');
$table->string('picture_title')->nullable()->comment('Attribut title de l\'image.');
$table->string('picture_alt')->nullable()->comment('Attribut alt de l\'image.');
$table->string('picture_thumb')->nullable()->comment('La vignette du produit 300x300 px.');
$table->string('picture_thumb_title')->nullable()->comment('Attribut title de l\'image.');
$table->string('picture_thumb_alt')->nullable()->comment('Attribut alt de l\'image.');
$table->json('labels')->comment('List of Products\\ProductLabelEnum');
$table->text('ingredients')->comment('Ingredients');
$table->text('preparation_advices')->comment('Preparation advices');
$table->text('sales_pitch')->comment('Arguments de vente');
$table->text('allergen_list')->comment('Liste allergene issues de inco');
$table->unsignedTinyInteger('number_portion')->comment('Portion number');
$table->boolean('show_ingredients')->comment('Afficher en front ou non.');
$table->boolean('show_preparation_advices')->comment('Afficher en front ou non.');
$table->boolean('show_sales_pitch')->comment('Afficher en front ou non.');
$table->boolean('show_allergen_list')->comment('Afficher en front ou non.');
$table->boolean('show_nutrionnal_values')->comment('Afficher en front ou non.');
$table->boolean('show_portions')->comment('Afficher en front ou non.');
$table->boolean('status')->comment('Le produit est visible ou non');
$table->timestamp('publish_at')->nullable()
->comment('The date where the product status state should be set on.');
$table->timestamp('unpublish_at')->nullable()
->comment('The date where the product status state should be set off.');
$table->foreignIdFor(Range::class)->constrained();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('products');
}
}

View file

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
// @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace
class CreateIncoNutritionalValuesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('inco_nutritional_values', function (Blueprint $table) {
$table->id();
$table->double('weight')->nullable();
$table->double('energy')->nullable();
$table->double('energy_kj')->nullable();
$table->double('protein')->nullable();
$table->double('carbohydrates')->nullable();
$table->double('sugar')->nullable();
$table->double('fat')->nullable();
$table->double('saturated_fatty_acids')->nullable();
$table->double('dietary_fiber')->nullable();
$table->double('salt')->nullable();
$table->foreignId('product_id');
$table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('inco_nutritional_values');
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\Breeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class BreederSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
DB::table('breeders')->truncate();
Breeder::factory(150)->create();
}
}

View file

@ -15,7 +15,11 @@ class DatabaseSeeder extends Seeder
{
$this->call([
NewsSeeder::class,
SliderSeeder::class
SliderSeeder::class,
BreederSeeder::class,
RangeSeeder::class,
RecipeSeeder::class,
ProductSeeder::class,
]);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\IncoNutritionalValue;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class IncoNutritionalValueSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
DB::table('inco_nutritional_values')->truncate();
IncoNutritionalValue::factory(150)->create();
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Database\Seeders;
use App\Models\IncoNutritionalValue;
use App\Models\Product;
use App\Models\Range;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class ProductSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
DB::table('inco_nutritional_values')->truncate();
Product::all()->map(function (Product $product) {
$product->deleteOrFail();
});
$ranges = Range::all();
Product::factory(150)->make()->each(function (Product $product) use ($ranges) {
\mt_srand();
if (\mt_rand(0, 2)) {
$product->range()->associate($ranges->random(1)->first());
$product->saveOrFail();
$product->fresh();
/** @var \App\Models\IncoNutritionalValue $incValue */
$incValue = IncoNutritionalValue::factory(1)->make()->get(0);
$incValue->product()->associate($product);
$incValue->saveOrFail();
}
});
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Database\Seeders;
use App\Models\Range;
use Illuminate\Database\Seeder;
class RangeSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Range::all()->map(function (Range $range) {
$range->deleteOrFail();
});
Range::factory(8)->create();
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace Database\Seeders;
use App\Models\Recipe;
use Illuminate\Database\Seeder;
class RecipeSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Recipe::truncate();
Recipe::factory(75)->create();
}
}

1514
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,12 +10,14 @@
"production": "mix --production"
},
"devDependencies": {
"@ckpack/vue-color": "^1.1.9",
"@fortawesome/fontawesome-free": "^6.1.1",
"@popperjs/core": "^2.11.5",
"@sipec/vue3-tags-input": "^3.0.4",
"@types/bootstrap": "^5.1.12",
"@types/cropperjs": "^1.3.0",
"@types/toastify-js": "^1.11.1",
"@types/vue-select": "^3.16.0",
"@types/webpack-env": "^1.17.0",
"axios": "^0.21",
"bootstrap": "^5.1.3",
@ -28,14 +30,13 @@
"resolve-url-loader": "^5.0.0",
"sass": "^1.52.1",
"sass-loader": "^12.6.0",
"slugify": "^1.6.5",
"sweetalert2": "^11.4.17",
"toastify-js": "^1.11.2",
"ts-loader": "^9.3.0",
"typescript": "^4.7.3",
"vue": "^3.2.36",
"vue-loader": "^16.8.3"
},
"dependencies": {
"autoprefixer": "10.4.5"
"vue-loader": "^16.8.3",
"vue-select": "^4.0.0-beta.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -5,5 +5,9 @@ return [
'news' => 'News',
'tags' => 'Tags',
'sliders' => 'Sliders',
'ranges' => 'Ranges',
'products' => 'Products',
'recipes' => 'Recipes',
'breeders' => 'Breeders',
]
];

View file

@ -5,5 +5,9 @@ return [
'news' => 'Actualités',
'tags' => 'Tags',
'sliders' => 'Sliders',
'ranges' => 'Gammes',
'products' => 'Produits',
'recipes' => 'Recettes',
'breeders' => 'Éleveurs',
]
];

View file

@ -1,5 +1,5 @@
// LIBS
@import './common.scss';
@import "./common.scss";
.icon-size {
font-size: 20px;
@ -14,3 +14,17 @@
@extend .icon-size;
color: seagreen;
}
.table.fixAction {
& th:last-child,
& td:last-child {
// * Shrink
width: 0.1%;
white-space: nowrap;
// * Sticky
position: sticky;
right: 0;
background-color: $gray-200;
color: $gray-900;
}
}

View file

@ -0,0 +1,82 @@
import axios, { CancelTokenSource } from "axios";
import route from "../modules/route";
class CKUploadAdaptater {
route: string | null = "";
cancel: CancelTokenSource | null = null;
// @ts-ignore
constructor(loader) {
// The file loader instance to use during the upload.
// @ts-ignore
this.loader = loader;
this.cancel = axios.CancelToken.source();
}
// Starts the upload process.
upload() {
// @ts-ignore
return this.loader.file.then(
(file: File) =>
new Promise((resolve, reject) => {
this._initRequest();
this._sendRequest(file, resolve, reject);
})
);
}
// Aborts the upload process.
abort() {
if (this.cancel) {
this.cancel.cancel();
}
}
// Initializes the XMLHttpRequest object using the URL passed to the constructor.
_initRequest() {
this.route = route.methods.route("crud-policies.upload");
}
// Prepares the data and sends the request.
_sendRequest(
file: File,
resolve: (value: unknown) => void,
reject: (reason?: any) => void
) {
// Prepare the form data.
const data = new FormData();
data.append("upload", file);
const headers = Object.assign(
{},
window.axios ? window.axios.defaults.headers.common : {}
);
headers["Content-Type"] = "multipart/form-data";
if (!this.route) {
throw new Error("Route is not set");
}
axios
.post(this.route, data, {
withCredentials: true,
headers: headers,
onUploadProgress: (evt) => {
// @ts-ignore
this.loader.uploadTotal = evt.total;
// @ts-ignore
this.loader.uploaded = evt.loaded;
},
})
.then((response) => {
resolve({
default: response.data.url,
});
})
.catch((error) => {
console.error(error);
reject("Le fichier à été marbré");
});
}
}
export { CKUploadAdaptater };

View file

@ -7,7 +7,12 @@ const bootstrap = require("bootstrap");
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
axios.defaults.headers.common = {
'Accept': 'application/json, text/plain, */*',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN' : document.querySelector('meta[name="csrf-token"]')!.getAttribute('content')
};
window.axios = axios;
window.bootstrap = bootstrap;

View file

@ -17,6 +17,7 @@
import { defineComponent, ref } from "vue";
import trans from "../../modules/trans";
import "./../../../js/vendor/ckeditor/build/ckeditor";
import { CKUploadAdaptater } from "../../back/CkUploadAdaptater";
export default defineComponent({
mixins: [trans],
@ -26,10 +27,10 @@ export default defineComponent({
}
},
mounted() {
const json = String(this.$attrs.json ?? ""),
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this.name = data.name;
this.internalText = data.text;
this.internalText = data.text ? String(data.text) : "";
this.internalId = data.id ? data.id : "";
this.internalRequired = Boolean(data.required);
this.makeSuperBuild();
@ -71,6 +72,16 @@ export default defineComponent({
wordCounter = this.$refs.wordCounter as HTMLElement;
// @ts-ignore
ClassicEditor.create(ckeditor, {
extraPlugins: [
// @ts-ignore
(editor) => {
// @ts-ignore
editor.plugins.get("FileRepository").createUploadAdapter = (loader) => {
// Configure the URL to the upload script in your back-end here!
return new CKUploadAdaptater(loader);
};
},
],
wordCount: {
container: wordCounter,
// @ts-ignore
@ -125,7 +136,7 @@ export default defineComponent({
"undo",
"redo",
"|",
"laravelFileManager",
"insertImage",
"mediaEmbed",
"blockQuote",
"|",

View file

@ -0,0 +1,263 @@
<template>
<div
:class="`input-group color-picker-component ${_rgbaMode ? '' : 'noTransparent'}`"
ref="colorpicker"
>
<div
:id="`colorPickerFakeInput${_id}`"
class="d-inline-block"
ref="colorPickerFakeInput"
>
<div class="form-check form-switch d-inline-block pt-3">
<b>
<label v-if="_label.length" :for="_id ? _id : ''" class="form-label">
{{ _label }}
</label>
</b>
<input
:id="_id"
type="color"
class="form-control form-control-color"
:value="_internalHex"
:title="_title"
:aria-describedby="_ariaDescribedby"
data-bs-toggle="tooltip"
v-on:click.prevent="togglePicker()"
/>
<input :name="_name" type="text" class="d-none" :value="_value" />
</div>
<div
:class="`form-check form-switch pt-3 ${_nullable ? 'd-inline-block' : 'd-none'}`"
>
<input
ref="nullableInput"
:id="`checkbox${_id}`"
class="form-check-input me-1 mt-4"
type="checkbox"
data-bs-toggle="tooltip"
:title="__('Rendre la couleur transparente ?')"
:checked="_value === null ? true : false"
v-on:change="updateValue"
/>
<label class="form-check-label user-select-none mt-3" :for="`checkbox${_id}`">
{{ __("Transparent ?") }}
</label>
</div>
<div :class="`chromePicker ${_displayPicker ? 'd-inline-block' : 'd-none'}`">
<chrome-picker ref="picker" v-model="_internalValue" />
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
// @ts-ignore
import { Chrome } from "@ckpack/vue-color";
import trans from "../../modules/trans";
import Tooltip from "bootstrap/js/dist/tooltip";
export default defineComponent({
inheritAttrs: false,
mixins: [trans],
components: {
"chrome-picker": Chrome,
},
mounted() {
this.__nullableInput = this.$refs.nullableInput as HTMLInputElement;
this.__colorPickerFakeInput = this.$refs.colorPickerFakeInput as HTMLElement;
this.__picker = this.$refs.picker as Chrome;
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this._id = this.id ? this.id : data.id;
this._name = this.name ? this.name : data.name;
this._value = this.value ? this.value : data.value;
this._title = this.title ? this.title : data.title;
this._label = this.label ? this.label : data.label;
this._ariaDescribedby = this.ariaDescribedby
? this.ariaDescribedby
: data.ariaDescribedby;
this._nullable = this.nullable ? this.nullable : data.nullable ? true : false;
this._rgbaMode = this.rgbaMode ? this.rgbaMode : data.rgbaMode ? true : false;
if (!this._nullable) {
this._internalValue = this._value
? this._value
: this._rgbaMode
? "rgba(0,0,0,0)"
: "#000000";
} else {
this._internalValue = this._value ?? "";
}
this.$nextTick(() => {
this._internalHex = this.__picker.$data.val.hex;
});
this.initTooltips();
},
props: {
id: {
type: String as PropType<string | null>,
default: null,
},
name: {
type: String as PropType<string | undefined>,
},
value: {
type: String as PropType<string | null>,
default: null,
},
title: {
type: String as PropType<string | undefined>,
},
label: {
type: String as PropType<string | undefined>,
},
nullable: {
type: Boolean as PropType<boolean | undefined>,
},
rgbaMode: {
type: Boolean as PropType<boolean>,
default: false,
},
ariaDescribedby: {
type: String as PropType<string | undefined>,
},
},
data(): {
_id: string;
_name: string;
_value: string | null;
_title: string;
_label: string;
_nullable: boolean;
_rgbaMode: boolean;
_ariaDescribedby: string;
_internalValue:
| string
| { hex: string; rgba: { a: string; b: string; g: string; r: string } };
_internalHex: string;
_displayPicker: boolean;
__nullableInput: HTMLInputElement | null;
__colorPickerFakeInput: HTMLElement | null;
__picker: Chrome | null;
} {
return {
_id: "",
_name: "",
_value: "",
_title: "",
_label: "",
_nullable: false,
_rgbaMode: false,
_ariaDescribedby: "",
_internalValue: "",
_internalHex: "",
_displayPicker: false,
__nullableInput: null,
__colorPickerFakeInput: null,
__picker: null,
};
},
watch: {
_internalValue() {
this.updateValue();
},
},
methods: {
updateValue() {
if (this.__nullableInput?.checked) {
this.hidePicker();
this._value = "";
this._internalHex = "";
return;
}
this.$nextTick(() => {
this._internalHex = this.__picker.$data.val.hex;
});
if (typeof this._internalValue === "string") {
this._value = this._internalValue;
return;
}
const isNull = this.__nullableInput?.checked ? true : false;
if (this._rgbaMode) {
const rgba = this._internalValue?.rgba;
this._value =
!isNull && rgba
? `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`
: this._nullable
? null
: "";
return;
}
this._value =
!isNull && this._internalValue
? this._internalValue.hex
: this._nullable
? null
: "";
},
showPicker() {
document.addEventListener("click", this.documentClick);
this._displayPicker = true;
const input = this.__nullableInput;
if (!input) {
throw new Error("input missing");
}
input.checked = false;
},
hidePicker() {
document.removeEventListener("click", this.documentClick);
this._displayPicker = false;
},
togglePicker() {
this._displayPicker ? this.hidePicker() : this.showPicker();
},
documentClick(e: Event) {
const colorpickerComponent = this.__colorPickerFakeInput,
target = e.target;
if (
colorpickerComponent !== target &&
colorpickerComponent &&
(target as HTMLElement).closest(`#colorPickerFakeInput${this._id}`)?.id !==
colorpickerComponent.id
) {
this.hidePicker();
}
},
initTooltips() {
// * INIT Bootstrap tooltips
let tooltipTriggerList = [].slice.call(
document.querySelectorAll('.color-picker-component [data-bs-toggle="tooltip"]')
);
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new Tooltip(tooltipTriggerEl, {
delay: { show: 1000, hide: 300 },
});
});
},
},
});
</script>
<style lang="scss">
.color-picker-component {
position: relative;
.chromePicker {
position: absolute;
top: 6.4rem;
left: 0;
z-index: 9;
}
&.noTransparent .vc-chrome-alpha-wrap {
display: none;
}
}
</style>

View file

@ -0,0 +1,165 @@
<template>
<div class="taggableDropdown">
<div v-if="errorMessages.length">
<span class="d-block text-danger" v-for="errorMessage of errorMessages">
{{ errorMessage }}
</span>
</div>
<div class="d-inline-block tagInput">
<VueTagsInputCls
v-model="tag"
:tags="tags"
:autocomplete-items="filteredItems"
:autocomplete-min-length="0"
@before-adding-tag="beforeTagSave"
@tags-changed="changedTags"
:max-tags="6"
:placeholder="__('Ajoutez ici vos étiquettes')"
/>
<div class="text-center py-2" v-if="waitingListObjects.length">
<button class="btn btn-sm btn-dark ms-2" v-on:click.prevent="cancelTags">
{{ __("Annuler") }}
</button>
</div>
</div>
<template v-for="(sTag, index) in tags" :key="index">
<input
:name="`${name}[${String(index)}][value]`"
type="hidden"
:value="sTag.value"
/>
<input
:name="`${name}[${String(index)}][label]`"
type="hidden"
:value="sTag.text"
/>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import type VueTagsInput from "@sipec/vue3-tags-input";
import { VueTagsInput as VueTagsInputCls } from "@sipec/vue3-tags-input";
import slugify from "slugify";
import route from "../../modules/route";
import toast from "../../modules/toast";
import trans from "../../modules/trans";
export default defineComponent({
mixins: [toast, route, trans],
components: {
VueTagsInputCls,
},
beforeMount() {
if (!this.$attrs.json) {
throw new Error("this component requires json");
}
},
mounted() {
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this.name = data.name;
this.tags = this.parseTags(data.value);
this.autocompleteItems = this.parseTags(data.tags);
},
data(): {
name: string;
tag: string;
tags: Array<CustomTag>;
autocompleteItems: Array<CustomTag>;
waitingListObjects: Array<VueTagsInput.IAddArgs>;
errorMessages: Array<string>;
} {
return {
name: "",
tag: "",
tags: [],
autocompleteItems: [],
waitingListObjects: [],
errorMessages: [],
};
},
methods: {
parseTags(tags: Array<CustomTag>): Array<CustomTag> {
return tags.map((tag: CustomTag) => {
tag.text = String(tag.label);
return tag;
});
},
cancelTags(): void {
this.$nextTick(() => {
this.waitingListObjects = [];
this.tag = "";
this.showErrorMessages([]);
});
},
beforeTagSave(obj: VueTagsInput.IAddArgs): void {
this.waitingListObjects = [];
this.showErrorMessages([]);
// * Tag exists in selected tags
if (this.tagExists(this.tags, obj.tag)) {
this.showErrorMessages([this.__("Cette étiquette est déjà dans la liste.")]);
return;
}
// * Tag exists in local list
let customTag = this.tagExists(this.autocompleteItems, obj.tag);
if (customTag) {
obj.tag = customTag;
this.tags.push(customTag);
this.tag = "";
return;
}
// * Add tag in waiting list
this.waitingListObjects.push(obj);
},
changedTags(newTags: Array<CustomTag>): void {
this.tags = newTags as Array<CustomTag>;
},
tagExists(items: Array<CustomTag>, tagToCheck: VueTagsInput.ITag): CustomTag | null {
let customTag: CustomTag | null = null;
items.filter((tag: CustomTag) => {
if (
tag.value === (tagToCheck as CustomTag).value ||
tag.label === slugify(tagToCheck.text)
) {
customTag = tag;
}
});
return customTag;
},
showErrorMessages(messages: Array<string>): void {
this.errorMessages = messages;
},
},
computed: {
filteredItems() {
return this.autocompleteItems.filter((i) => {
return i.text.toLowerCase().indexOf(this.tag.toLowerCase()) !== -1;
});
},
},
});
</script>
<style lang="scss">
@import "../../../../node_modules/bootstrap/scss/bootstrap-utilities.scss";
.taggableDropdown {
.tagInput {
width: 100%;
@include media-breakpoint-up(lg) {
width: 75%;
}
.vue-tags-input {
max-width: inherit !important;
}
.ti-autocomplete {
overflow-y: scroll !important;
max-height: 10rem !important;
}
}
}
</style>

View file

@ -2,9 +2,9 @@
<div class="container ImageInput p-0">
<div class="row">
<div :class="`col-12 ${_preview ? 'col-md-6' : ''} form-group`">
<label v-if="_preview" :for="_id" class="col-form-label"
><b>{{ __("Choisir un fichier") }}</b></label
>
<label v-if="_preview" :for="_id" class="col-form-label">
<b>{{ __("Choisir un fichier") }}</b>
</label>
<div class="input-group">
<button
v-on:click.prevent="chooseAFile"
@ -22,7 +22,7 @@
type="file"
class="d-none"
:name="_name"
accept="image/png,image/jpeg,.svg"
accept=".jpg,.jpeg,.gif,.png"
:aria-describedby="`Help${_id}`"
/>
<input
@ -89,8 +89,13 @@
v-if="_value"
:id="`PreviewHelp${_id}`"
class="form-text text-muted d-block"
>{{ __("Prévisualisation de l'image") }}</small
>
>{{ __("Prévisualisation de l'image") }}
<span v-if="_originalFile" class="fw-bold">
{{ humanFileSize(_originalFile.size) }}
{{ `hauteur ${_originalFileDimensions.height}px` }}
{{ `largeur ${_originalFileDimensions.width}px` }}
</span>
</small>
<img
v-if="_value"
class="img-fluid"
@ -111,7 +116,9 @@
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ __("Modification d'image avant import") }}</h5>
<h5 class="modal-title">
{{ __("Modification d'image avant import") }}
</h5>
<button
type="button"
class="btn-close"
@ -233,7 +240,11 @@
</div>
<div class="mt-1 ms-4 rotateDrag">
<label for="range" class="form-label me-4"
>{{ __(":deg degrès", { deg: String(_rotationText) }) }}
>{{
__(":deg degrès", {
deg: String(_rotationText),
})
}}
</label>
<input
ref="rotationSlider"
@ -297,7 +308,9 @@
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ __("Prévisualisation") }}</h5>
<h5 class="modal-title">
{{ __("Prévisualisation") }}
</h5>
<button
type="button"
class="btn-close"
@ -336,6 +349,9 @@ import Cropper from "cropperjs";
import { Tooltip } from "bootstrap";
import trans from "../../modules/trans";
const UNITS = ["byte", "kilobyte", "megabyte", "gigabyte", "terabyte", "petabyte"];
const BYTES_PER_KB = 1024;
export default defineComponent({
mixins: [trans],
emits: ["browseFile"],
@ -346,7 +362,9 @@ export default defineComponent({
document.querySelectorAll('.ImageInput [data-bs-toggle="tooltip"]')
);
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new Tooltip(tooltipTriggerEl, { delay: { show: 1000, hide: 300 } });
return new Tooltip(tooltipTriggerEl, {
delay: { show: 1000, hide: 300 },
});
});
if (json.length) {
// * Init uppon Laravel Blade
@ -446,7 +464,11 @@ export default defineComponent({
_hasModdedImage: boolean;
_cropper: Cropper | null;
_originalFile: null;
_originalFile: File | null;
_originalFileDimensions: {
height: number;
width: number;
};
_originalFileName: string;
_previewStyle: string;
} {
@ -472,6 +494,10 @@ export default defineComponent({
_cropper: null,
_originalFile: null,
_originalFileDimensions: {
height: 0,
width: 0,
},
_originalFileName: "",
_previewStyle: "",
};
@ -533,25 +559,25 @@ export default defineComponent({
},
reset() {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
return;
}
this._cropper.reset();
},
zoom(v: number) {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
return;
}
this._cropper.zoom(v);
},
move(x: number, y: number) {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
return;
}
this._cropper.move(x, y);
},
scale(horizontal: boolean) {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
return;
}
if (horizontal) {
this._scaleX = -1 / this._scaleX;
@ -617,7 +643,9 @@ export default defineComponent({
false
);
if (el.files[0]) {
self._originalFile = el.files[0];
reader.readAsDataURL(el.files[0]);
reader.onloadend = () => this.fetchDataUrlImageSizes(String(reader.result));
this._valueFileName = el.files[0].name;
}
},
@ -636,6 +664,7 @@ export default defineComponent({
if (!actualImage || !actualImage.files) {
throw new Error("actualImage is not defined");
}
self._originalFile = actualImage.files[0];
self._originalFileName = actualImage.files[0].name;
imgEditor.src = URL.createObjectURL(actualImage.files[0]);
@ -678,12 +707,26 @@ export default defineComponent({
}
return new Blob([u8arr], { type: mime[1] });
},
fetchDataUrlImageSizes(dataUrl: string) {
const img = new Image(),
self = this;
try {
img.onload = () => {
self._originalFileDimensions.width = img.width;
self._originalFileDimensions.height = img.height;
};
img.src = dataUrl;
} catch (e) {
self._originalFileDimensions.width = img.width;
self._originalFileDimensions.height = img.height;
}
},
exportToBlob() {
const self = this,
actualImage = this.$refs.actualImage as HTMLInputElement,
container = new DataTransfer();
if (!(self._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
return;
}
if (!actualImage.files) {
throw new Error();
@ -698,13 +741,31 @@ export default defineComponent({
imageSmoothingQuality: "high",
})
.toDataURL("image/png");
this.fetchDataUrlImageSizes(self._value);
const file = new File([self.dataURLtoBlob(self._value)], self._originalFileName, {
type: "image/png",
lastModified: new Date().getTime(),
});
this._originalFile = file;
container.items.add(file);
actualImage.files = container.files;
},
humanFileSize(sizeBytes: number | bigint): string {
let size = Math.abs(Number(sizeBytes));
let u = 0;
while (size >= BYTES_PER_KB && u < UNITS.length - 1) {
size /= BYTES_PER_KB;
++u;
}
return new Intl.NumberFormat([], {
style: "unit",
unit: UNITS[u],
unitDisplay: "short",
maximumFractionDigits: 1,
}).format(size);
},
},
});
</script>
@ -743,12 +804,6 @@ export default defineComponent({
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
@include media-breakpoint-up(md) {
& > div {
position: absolute;
}
}
}
input.rightAligned {
direction: ltr !important;

View file

@ -0,0 +1,97 @@
<template>
<WordCounter :id="_id" />
<textarea
ref="textarea"
:id="_id"
:class="`inputTextAutoComponent ${_class}`"
:name="_name"
:aria-describedby="_ariaDescribedby"
:placeholder="_placeholder"
:required="_required"
v-on:input="onInput"
:json="'{}'"
>{{ _value }}</textarea
>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from "vue";
import trans from "../../modules/trans";
import WordCounter from "./WordCounterComponent.vue";
const textarea = ref<HTMLTextAreaElement | null>(null);
export default defineComponent({
inheritAttrs: false,
mixins: [trans],
components: {
WordCounter,
},
setup() {
return { textarea };
},
beforeMount() {
if (!this.$attrs.json) {
throw new Error("this component requires json");
}
},
mounted() {
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this._id = String(data.id);
this._class = String(data.class);
this._name = String(data.name);
this._placeholder = String(data.placeholder);
this._ariaDescribedby = String(data.ariaDescribedby);
this._value = String(data.value);
if (data.required) {
this._required = true;
}
if (this.textarea) {
// Default input init
this.textarea.setAttribute("style", `height: 1rem;overflow-y: hidden;`);
}
// * Init input
this.$nextTick(this.onInput);
},
data(): {
_id: string;
_class: string;
_name: string;
_placeholder: string;
_ariaDescribedby: string;
_required: boolean;
_value: string;
_textarea: HTMLTextAreaElement | null;
} {
return {
_id: "",
_class: "",
_name: "",
_placeholder: "",
_ariaDescribedby: "",
_required: false,
_value: "",
_textarea: null,
};
},
methods: {
onInput() {
const el = textarea.value;
if (!el) {
throw new Error("undefined textarea element");
}
// * Remove every new line char
el.value = el.value.replace(/[\n\r]/, "");
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
},
},
});
</script>
<style lang="scss">
.inputTextAutoComponent {
resize: none;
}
</style>

View file

@ -0,0 +1,273 @@
<template>
<div class="ModelLink form-control">
<VueSelect
:placeholder="_placeholder"
:_loading="_loading"
v-model="_value"
:clearable="true"
label="label"
:options="_data"
v-on:search="search"
:disabled="_readonly || _disabled"
:filterable="true"
:searchable="true"
>
<template v-slot:option="{ option }: { option: VSelectProps }">
{{
option.label.length >= _maxLength
? option.label.substring(0, _maxLength) + ".."
: option.label
}}
</template>
<template
v-slot:no-options="{ search, searching }: { search: string, searching: boolean }"
>
<template v-if="searching">
{{ __("Aucun résultat pour") }}
<em>{{ search }}</em> .
</template>
<em v-else style="opacity: 0.5">{{ __("Chercher un lien du site") }}</em>
</template>
<template v-slot:list-footer>
<li class="pagination" v-if="_data.length">
<button
v-on:click.prevent="_page -= 1"
:disabled="!_hasPrevPage"
class="btn btn-sm btn-primary mt-2 mb-2 ms-2 me-2"
>
{{ __("précédent") }}
</button>
<button
v-on:click.prevent="_page += 1"
:disabled="!_hasNextPage"
class="btn btn-sm btn-primary mt-2 mb-2"
>
{{ __("suivant") }}
</button>
</li>
</template>
</VueSelect>
<input v-if="_isJson" type="hidden" :name="String(_name)" :value="_value?.value" />
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import VueSelect from "vue-select";
import trans from "../../modules/trans";
import route from "../../modules/route";
import axios from "axios";
type SelectModel = {
class: string;
label: string;
value: string;
};
export default defineComponent({
mixins: [trans, route],
components: {
VueSelect,
},
beforeMount() {
if (!this.$attrs.json) {
throw new Error("this component requires json");
}
},
mounted() {
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
if (Object.keys(data).length) {
this._isJson = true;
this._name = data.name;
}
this._disabled = this.disabled ? this.disabled : data.disabled ? true : false;
this._readonly = this.readonly ? this.readonly : data.readonly ? true : false;
this._placeholder = this.placeholder ? this.placeholder : data.placeholder;
this._maxLength = this.maxLength ? this.maxLength : data.maxLength;
this.parseValue(this.value ? this.value : data.value);
this.search();
},
props: {
disabled: {
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: "",
},
value: {
type: String,
default: "",
},
maxLength: {
type: Number,
default: 35,
},
},
data(): {
_isJson: boolean;
_name: string | null;
_disabled: boolean;
_readonly: boolean;
_placeholder: string;
_maxLength: number;
_value: SelectModel | null;
_loading: boolean;
_searchText: string;
_data: SelectModel[];
_page: number;
_perPage: number;
_hasNextPage: boolean;
_hasPrevPage: boolean;
} {
return {
_isJson: false,
_name: null,
_disabled: false,
_readonly: false,
_placeholder: "",
_value: null,
_loading: false,
_searchText: "",
_data: [],
_page: 1,
_perPage: 15,
_hasNextPage: true,
_hasPrevPage: false,
_maxLength: 65,
};
},
watch: {
_page() {
if (this._page < 1) {
this._page = 1;
}
if (this._page > 1) {
this._hasPrevPage = true;
} else {
this._hasPrevPage = false;
}
this.search();
},
_data() {
if (this._data.length == this._perPage) {
this._hasNextPage = true;
} else {
this._hasNextPage = false;
}
},
value() {
this.parseValue(this.value);
},
_value() {
this.$emit("input", this._value ? this._value.value : "");
},
},
methods: {
search(search = null) {
if (search !== false && search !== null) {
this._searchText = search;
}
if (!this._searchText) {
return;
}
let routeUrl = route.methods.route("bo.ranges.search-uri");
if (!routeUrl) {
throw new Error(`undefined route bo.ranges.search-uri`);
}
const url = new URL(routeUrl);
routeUrl = `${url.origin}${url.pathname}?`;
routeUrl += "page=" + encodeURIComponent(this._page);
routeUrl += "&search=" + encodeURIComponent(this._searchText);
this._loading = true;
axios
.get(routeUrl)
.then((response) => {
this.parseResponse(response.data.data);
})
.catch((error) => {
console.error(error);
})
.then(() => {
this._loading = false;
});
},
parseValue(value: string | null) {
if (!value) {
this._value = null;
return;
}
let routeUrl = route.methods.route("bo.ranges.model-title");
if (!routeUrl) {
throw new Error(`undefined route bo.ranges.model-title`);
}
const url = new URL(routeUrl),
model = value.replace(/^#/, "").replace(/#$/, "").split("#");
routeUrl = `${url.origin}${url.pathname}?`;
routeUrl += "model=" + encodeURIComponent(model[0]);
routeUrl += "&id=" + encodeURIComponent(model[1]);
axios.get(routeUrl).then((response) => {
let label = String(response.data as string);
label =
label.length >= this._maxLength
? label.substring(0, this._maxLength) + ".."
: label;
if (label) {
this._value = this.parseResource({
class: model[0],
label: label,
value: model[1],
});
} else {
console.error("empty data response");
}
});
},
parseResource(model: SelectModel): SelectModel {
let label = model.label;
switch (model.class) {
case "App\\Models\\Recipe":
label = `${this.__("models.classes.recipes")} - ${model.label}`;
break;
case "App\\Models\\Breeder":
label = `${this.__("models.classes.breeders")} - ${model.label}`;
break;
}
return {
label: label,
class: model.class,
value: `#${model.class}#${model.value}#`,
};
},
parseResponse(data: SelectModel[]) {
const out: SelectModel[] = [];
for (const model of data) {
out.push(this.parseResource(model));
}
// pass _searchText if is valid url and nothing else is found
if (out.length) {
this._data = out;
}
},
},
});
</script>
<style lang="scss">
@import "../../../../node_modules/vue-select/dist/vue-select.css";
.ModelLink {
padding: 0 !important;
.vs__dropdown-toggle {
border: 0 !important;
}
}
</style>

View file

@ -1,15 +1,30 @@
<template>
<div class="taggableDropdown">
<VueTagsInputCls
v-model="tag"
:tags="tags"
:autocomplete-items="filteredItems"
:autocomplete-min-length="0"
@before-adding-tag="beforeTagSave"
@tags-changed="changedTags"
:max-tags="6"
placeholder="Ajoutez ici vos étiquettes"
/>
<div v-if="errorMessages.length">
<span class="d-block text-danger" v-for="errorMessage of errorMessages">
{{ errorMessage }}
</span>
</div>
<div class="d-inline-block tagInput">
<VueTagsInputCls
v-model="tag"
:tags="tags"
:autocomplete-items="filteredItems"
:autocomplete-min-length="0"
@before-adding-tag="beforeTagSave"
@tags-changed="changedTags"
:max-tags="6"
:placeholder="__('Ajoutez ici vos étiquettes')"
/>
<div class="text-center py-2" v-if="waitingListObjects.length">
<button class="btn btn-sm btn-primary ms-2" v-on:click.prevent="addTags">
{{ __("Créer et ajouter à la liste") }}
</button>
<button class="btn btn-sm btn-dark ms-2" v-on:click.prevent="cancelTags">
{{ __("Annuler") }}
</button>
</div>
</div>
<template v-for="(sTag, index) in tags" :key="index">
<input :name="`${name}[${String(index)}][id]`" type="hidden" :value="sTag.id" />
<input :name="`${name}[${String(index)}][name]`" type="hidden" :value="sTag.text" />
@ -21,11 +36,13 @@
import { defineComponent } from "vue";
import type VueTagsInput from "@sipec/vue3-tags-input";
import { VueTagsInput as VueTagsInputCls } from "@sipec/vue3-tags-input";
import slugify from "slugify";
import route from "../../modules/route";
import toast from "../../modules/toast";
import trans from "../../modules/trans";
export default defineComponent({
mixins: [toast, route],
mixins: [toast, route, trans],
components: {
VueTagsInputCls,
},
@ -35,7 +52,7 @@ export default defineComponent({
}
},
mounted() {
const json = String(this.$attrs.json ?? ""),
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this.name = data.name;
this.tags = this.parseTags(data.value);
@ -46,42 +63,84 @@ export default defineComponent({
tag: string;
tags: Array<CustomTag>;
autocompleteItems: Array<CustomTag>;
waitingListObjects: Array<VueTagsInput.IAddArgs>;
errorMessages: Array<string>;
} {
return {
name: "",
tag: "",
tags: [],
autocompleteItems: [],
waitingListObjects: [],
errorMessages: [],
};
},
methods: {
parseTags(tags: any) {
parseTags(tags: Array<CustomTag>): Array<CustomTag> {
return tags.map((tag: CustomTag) => {
tag.text = String(tag.name);
tag.slug = String(tag.slug);
return tag;
});
},
beforeTagSave(obj: VueTagsInput.IAddArgs) {
const self = this;
if (
self.autocompleteItems.filter((tag: CustomTag) => {
return tag.id === (obj.tag as CustomTag).id;
}).length
) {
obj.addTag();
addTags(): void {
for (const obj of this.waitingListObjects) {
this.ajaxSaveTag(obj);
}
},
cancelTags(): void {
this.$nextTick(() => {
this.waitingListObjects = [];
this.tag = "";
this.showErrorMessages([]);
});
},
beforeTagSave(obj: VueTagsInput.IAddArgs): void {
this.waitingListObjects = [];
this.showErrorMessages([]);
// * Tag exists in selected tags
if (this.tagExists(this.tags, obj.tag)) {
this.showErrorMessages([this.__("Cette étiquette est déjà dans la liste.")]);
return;
}
const storeTagRoute = route.methods.route("bo.tags.store");
// * Tag exists in local list
let customTag = this.tagExists(this.autocompleteItems, obj.tag);
if (customTag) {
obj.tag = customTag;
this.tags.push(customTag);
this.tag = "";
return;
}
// * Add tag in waiting list
this.waitingListObjects.push(obj);
},
ajaxSaveTag(obj: VueTagsInput.IAddArgs): void {
const self = this,
storeTagRoute = route.methods.route("bo.tags.jsonStore");
if (!storeTagRoute) {
throw new Error("Undefined route bo.tags.store");
throw new Error("Undefined route bo.tags.jsonStore");
}
window.axios
.post(storeTagRoute, { name: this.tag })
.post(storeTagRoute, { name: self.tag })
.then((reponse) => {
this.autocompleteItems.push(this.parseTags([reponse.data])[0]);
obj.addTag();
const parsedTag = self.parseTags([reponse.data])[0] as CustomTag,
tag = obj.tag as CustomTag;
self.autocompleteItems.push(parsedTag);
tag.id = parsedTag.id;
tag.slug = parsedTag.slug;
obj.tag = tag;
this.tags.push(tag);
})
.catch((error) => {
let message = "Une erreur est survenue merci de ressayer plus tard";
if (error.response.status === 422) {
message =
error.response?.data?.errors?.name?.[0] ||
error.response?.data?.errors?.slug?.[0] ||
message;
this.showErrorMessages([message]);
return;
}
if (error.response) {
console.error(error.response.data);
} else if (error.request) {
@ -90,11 +149,15 @@ export default defineComponent({
// Something happened in setting up the request that triggered an Error
console.error("Error", error.message);
}
self.errorNotify("Une erreur est survenue merci de ressayer plus tard");
self.errorNotify(message);
})
.then(() => {
this.waitingListObjects = [];
this.tag = "";
});
},
beforeTagDelete(obj: VueTagsInput.IDeleteArgs) {
let self = this;
beforeTagDelete(obj: VueTagsInput.IDeleteArgs): void {
const self = this;
if (obj.tag) {
const destroyTagRoute = route.methods.route("bo.tags.destroy");
if (!destroyTagRoute) {
@ -109,9 +172,24 @@ export default defineComponent({
});
}
},
changedTags(newTags: Array<CustomTag>) {
changedTags(newTags: Array<CustomTag>): void {
this.tags = newTags as Array<CustomTag>;
},
tagExists(items: Array<CustomTag>, tagToCheck: VueTagsInput.ITag): CustomTag | null {
let customTag: CustomTag | null = null;
items.filter((tag: CustomTag) => {
if (
tag.id === (tagToCheck as CustomTag).id ||
tag.slug === slugify(tagToCheck.text)
) {
customTag = tag;
}
});
return customTag;
},
showErrorMessages(messages: Array<string>): void {
this.errorMessages = messages;
},
},
computed: {
filteredItems() {
@ -124,10 +202,23 @@ export default defineComponent({
</script>
<style lang="scss">
@import "../../../../node_modules/bootstrap/scss/bootstrap-utilities.scss";
.taggableDropdown {
.ti-autocomplete {
overflow-y: scroll !important;
max-height: 10rem !important;
.tagInput {
width: 100%;
@include media-breakpoint-up(lg) {
width: 75%;
}
.vue-tags-input {
max-width: inherit !important;
}
.ti-autocomplete {
overflow-y: scroll !important;
max-height: 10rem !important;
}
}
}
</style>

View file

@ -2,11 +2,11 @@
<div class="wordCounter">
<span class="words"
><u>{{ __("Mots") }}</u
>&nbsp;:&nbsp;<b>{{ words }}</b></span
>&nbsp;:&nbsp;<b>{{ _words }}</b></span
>
<span class="chars"
><u>{{ __("Caractères") }}</u
>&nbsp;:&nbsp;<b>{{ chars }}</b></span
>&nbsp;:&nbsp;<b>{{ _chars }}</b></span
>
</div>
</template>
@ -17,63 +17,81 @@ import trans from "../../modules/trans";
export default defineComponent({
mixins: [trans],
beforeMount() {
if (!this.$attrs.json) {
throw new Error("this component requires json");
mounted() {
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this._id = String(this.id);
if (data.id) {
this._id = data.id;
}
},
mounted() {
const json = String(this.$attrs.json ?? ""),
data = JSON.parse(json);
this.id = data.id;
props: {
id: {
type: String,
default: null,
},
},
data(): {
id: string;
words: number;
chars: number;
input: HTMLInputElement | null;
_id: string;
_words: number;
_chars: number;
_input: HTMLInputElement | HTMLTextAreaElement | null;
} {
return {
id: "",
words: 0,
chars: 0,
input: null,
_id: "",
_words: 0,
_chars: 0,
_input: null,
};
},
watch: {
id() {
if (!this.id) {
throw new Error("Missing ID");
}
const input = document.getElementById(this.id);
if (!input || !(input instanceof HTMLInputElement)) {
throw new Error(`Input ${this.id} could not be found`);
}
this.input = input;
this._id = this.id;
},
input() {
if (!this.input) {
throw new Error("input has to be set");
_id() {
if (!this._id) {
return;
}
if (this.input && !(this.input instanceof HTMLInputElement)) {
throw new Error("input as to be HTMLInputElement");
this.$nextTick(() => {
const input = document.getElementById(this._id);
if (
!input ||
!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)
) {
throw new Error(`${this._id} is not a input or textarea`);
}
this._input = input;
});
},
_input() {
if (
!this._input ||
!(
this._input instanceof HTMLInputElement ||
this._input instanceof HTMLTextAreaElement
)
) {
throw new Error(`${this._id} is not a input or textarea`);
}
this.input.addEventListener("input", (e) => {
this._input.addEventListener("input", (e) => {
const el = e.target;
if (!el || !(el instanceof HTMLInputElement)) {
throw new Error();
if (
!el ||
!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)
) {
throw new Error(`${this._id} is not a input or textarea`);
}
this.updateValues(el.value);
});
this.updateValues(this.input.value);
this.updateValues(this._input.value);
},
},
methods: {
updateValues(string: string) {
let chars, words;
[words, chars] = this.calculateValues(string);
this.words = words;
this.chars = chars;
this._words = words;
this._chars = chars;
},
calculateValues(string: string) {
const arr = string.split(" ");

View file

@ -0,0 +1,5 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View file

@ -53,7 +53,7 @@ window.addEventListener("DOMContentLoaded", () => {
const el = e.target;
if (!el || !(el instanceof HTMLFormElement)) {
throw new Error(
"confirmDoneJS can only be executed on an exising form"
"confirmSubmitJS can only be executed on an exising form"
);
}
confirm.methods.confirm(
@ -70,24 +70,42 @@ window.addEventListener("DOMContentLoaded", () => {
}
elements = document.getElementsByClassName("confirmJS");
for (let element of elements) {
element.addEventListener("submit", function (e) {
e.preventDefault();
const el = e.target;
if (!el || !(el instanceof HTMLFormElement)) {
throw new Error(
"confirmDoneJS can only be executed on an exising form"
);
}
confirm.methods.confirm(
"Confirmez",
"Êtes vous sure ?",
el,
function (response: SweetAlertResult<any>) {
(el as HTMLFormElement).submit();
},
{ icon: "warning" }
);
return false;
});
element.addEventListener(
"click",
function (e) {
if (!element.getAttribute("confirmJS")) {
e.preventDefault();
const el = e.target;
if (!el || !(el instanceof HTMLElement)) {
throw new Error(
"confirmJS can only be executed on a html element"
);
}
(async () => {
const promise = new Promise(
(resolve: (value: boolean) => void, reject) => {
console.log(el);
confirm.methods.confirm(
"Confirmez",
"Êtes vous sure ?",
el,
function (response: SweetAlertResult<any>) {
resolve(response.isConfirmed);
},
{ icon: "warning" }
);
return false;
}
);
if (await promise) {
element.setAttribute("confirmJS", "true");
el.click();
element.removeAttribute("confirmJS");
}
})();
}
},
false
);
}
});

View file

@ -1,7 +1,8 @@
export {};
import { AxiosStatic } from "axios";
import type VueTagsInput from "vue3-tags-input";
import type VueTagsInput from "@sipec/vue3-tags-input";
import type { VueSelectProps } from "vue-select";
// * Recursive List<string>
type StringList = Record<PropertyKey, string>;
@ -26,7 +27,7 @@ type Model = Record<
interface NestedModelInterface extends Model {}
type NestedStringList = NestedStringListInterface | StringList | string;
type NestedNumberList = NestedStringListInterface | NumberList | number;
type NestedNumberList = NestedNumberListInterface | NumberList | number;
type ModelList = Array<Model>;
declare global {
@ -34,6 +35,8 @@ declare global {
type LaravelModel = Model;
type LaravelModelList = ModelList;
type VSelectProps = VueSelectProps;
interface Window {
$displayMenu: (show: boolean) => void;
$toggleBasketMenu: () => void;

View file

@ -9,7 +9,7 @@ export default {
confirm(
title: string,
text: string = "",
self: HTMLElement,
self: Object,
after: (response: SweetAlertResult<any>) => void = (
response: SweetAlertResult<any>
) => {},

View file

@ -1,21 +1,22 @@
export default {
const route = {
methods: {
route(
routeName: string,
parameters: Record<string, number | string> = {}
): string | null {
let route =
Object.keys(window.__SYSTEM._routes).indexOf(routeName) !== -1
? window.__SYSTEM._routes[routeName]
const routes = route.flattenProperties(window.__SYSTEM._routes);
const routeUrl =
Object.keys(routes).indexOf(routeName) !== -1
? routes[routeName]
: null;
if (!route) {
return route;
if (!routeUrl) {
return routeUrl;
}
// copy parameters
const iParameters = Object.assign({}, parameters),
origin = window.location.origin,
hash = window.location.hash;
let pathname = route.replace(origin, ""),
let pathname = routeUrl.replace(origin, ""),
search = window.location.search;
// Replace in path name.
@ -55,4 +56,21 @@ export default {
return `${origin}${pathname}${search}${hash}`;
},
},
flattenProperties(
obj: Record<PropertyKey, any>,
parent: string | null = null,
res: Record<PropertyKey, any> = {}
): Record<PropertyKey, any> {
for (const key of Object.keys(obj)) {
const propName = parent ? parent + "." + key : key;
if (typeof obj[key] === "object") {
route.flattenProperties(obj[key], propName, res);
} else {
res[propName] = obj[key];
}
}
return res;
},
};
export default route;

View file

@ -44,7 +44,7 @@
<div class="form-check">
<input class="form-check-input" type="checkbox" name="remember" id="remember" {{ old('remember') ? 'checked' : '' }}>
<label class="form-check-label" for="remember">
<label class="form-check-label user-select-none" for="remember">
{{ __('Se souvenir de moi') }}
</label>
</div>

View file

@ -15,5 +15,15 @@
'model' => Str::singular(__('models.classes.sliders'))
]) }}" data-bs-toggle="tooltip">{{ __('models.classes.sliders') }}</a>
</li>
<li class="nav-item">
<a class="nav-link @if(request()->routeIs('bo.ranges.*')) active @endif" href="{{ route('bo.ranges.index') }}" title="{{ __('Administrer les :model', [
'model' => Str::singular(__('models.classes.ranges'))
]) }}" data-bs-toggle="tooltip">{{ __('models.classes.ranges') }}</a>
</li>
<li class="nav-item">
<a class="nav-link @if(request()->routeIs('bo.products.*')) active @endif" href="{{ route('bo.products.index') }}" title="{{ __('Administrer les :model', [
'model' => Str::singular(__('models.classes.products'))
]) }}" data-bs-toggle="tooltip">{{ __('models.classes.products') }}</a>
</li>
</ul>
@endauth

View file

@ -29,7 +29,7 @@
@break
@case(InputType::boolean()->value)
<div class="form-group">
<label>
<label class="user-select-none">
<input type="checkbox" name="data[{{$k}}][value]" value="1" @if (( old("data.$k.value") ?? $input['value'] ?? 0 ))checked @endif>
{{ $input['label'] }}*
</label>

View file

@ -8,5 +8,17 @@
if(!window.__SYSTEM._routes) {
window.__SYSTEM._routes = {}
}
window.__SYSTEM._routes['crud-policies.upload'] = '{{ route('crud-policies.upload') }}';
window.__SYSTEM._routes['crud-policies'] = {
upload: '{{ route('crud-policies.upload') }}'
};
window.__SYSTEM._routes.bo = {
tags: {
store: '{{ route('bo.tags.store') }}',
jsonStore: '{{ route('bo.tags.jsonStore') }}'
},
ranges: {
'model-title': '{{ route('bo.ranges.model-title') }}',
'search-uri': '{{ route('bo.ranges.search-uri') }}',
},
};
</script>

View file

@ -18,7 +18,7 @@
<div class="col-12 col-md-6 form-group">
<label for="newsModelPublished" class="col-form-label"><b>{{ __('Publication') }}</b></label>
@if(isset($newsModel) and $newsModel->published->equals(App\Enums\PublishedStatus::published()))
<span class="col-form-label ms-3 fst-italic">{{ __('Publié le :date', ['date' => $newsModel->published_at->isoFormat('DD/MM/YYYY à HH:mm')]) }}</span>
<span class="col-form-label ms-3 fst-italic">{{ __('Publié le :date', ['date' => $newsModel->published_at->formatLocalized('%d/%m/%Y à %I:%M')]) }}</span>
@endif
<select id="newsModelPublished" class="form-select" name="published" aria-describedby="newsModelPublishedHelp" required>
<option value="" @if(is_null(old('published', $newsModel->published ?? null))) selected @endif disabled>{{ __('Séléctionnez un statut de publication') }}</option>
@ -44,18 +44,29 @@
<label for="newsModelSeoTitle" class="col-form-label"><b>{{ __('Titre seo') }}</b></label>
<div class="word-counter" data-json='@json(['id' => 'newsModelSeoTitle'])'></div>
<input name="seo_title" type="text" value="{{ old('seo_title', $newsModel->seo_title ?? '') }}" class="form-control"
id="newsModelSeoTitle" aria-describedby="newsModelSeoTitleHelp" placeholder="{{ __('Un titre en 60 caractères maximum') }}" required>
id="newsModelSeoTitle" aria-describedby="newsModelSeoTitleHelp" placeholder="{{ __('Un titre en 70 caractères maximum') }}" required>
@include('back.modules.input-error', [
'inputName' => 'seo_title', 'helperId' => 'newsModelSeoTitleHelp' , 'helper' => __('Doit décrire précisément l\'article, requiert entre 53 et 60 caratères, balise <title>')
'inputName' => 'seo_title', 'helperId' => 'newsModelSeoTitleHelp' , 'helper' => __('Doit décrire précisément l\'article, recommandation pour un positionnement optimal dans les moteurs de recherche : entre 53 et 60 caractères, balise <title> ')
])
</div>
<div class="col-12 col-md-6 form-group">
<label for="newsModelSeoDescription" class="col-form-label"><b>{{ __('Description seo') }}</b></label>
<div class="word-counter" data-json='@json(['id' => 'newsModelSeoDescription'])'></div>
<input name="seo_description" type="text" value="{{ old('seo_description', $newsModel->seo_description ?? '') }}" class="form-control"
id="newsModelSeoDescription" aria-describedby="newsModelSeoDescriptionHelp" placeholder="{{ __('Une description de l\'article pour les moteurs de recherche') }}" required>
@php
$data = [
'id' => 'newsModelSeoDescription',
'class' => 'form-control',
'name' => 'seo_description',
'placeholder' => __('Une description de l\'article pour les moteurs de recherche'),
'ariaDescribedby' => 'newsModelSeoDescriptionHelp',
'value' => old('tags', $newsModel->tags ?? []),
'tags' => $tags,
'required' => true,
'value' => old('seo_description', $newsModel->seo_description ?? '')
];
@endphp
<div id="input-text-auto" data-json='@json($data)'></div>
@include('back.modules.input-error', [
'inputName' => 'seo_description', 'helperId' => 'newsModelSeoDescriptionHelp' , 'helper' => __('Doit décrire précisément l\'article, requiert entre 50 et 160 caratères, balise <meta> keywords')
'inputName' => 'seo_description', 'helperId' => 'newsModelSeoDescriptionHelp' , 'helper' => __(' Doit décrire précisément l\'article, requiert entre 50 et 160 caractères, balise <meta> description')
])
</div>
</div>

View file

@ -18,7 +18,7 @@
</div>
<div class="col-md-3">
<div class="input-group justify-content-end">
<a href="{{ route('bo.news.create') }}" class="btn btn-primary" title="{{ __('Ajouter nouveau :model', [
<a href="{{ route('bo.news.create') }}" class="btn btn-primary" title="{{ __('Ajouter une :model', [
'model' => Str::singular(__('models.classes.news'))
]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-plus"></i></a>
</div>
@ -34,74 +34,76 @@
</div>
<div class="row">
<div class="col">
<table class="table">
<thead>
@php
$cols = ['title' => __('Titre'), 'tags' => __('Tags'), 'published' => __('Publication'), 'updated_at' => __('Mis à jour le')];
@endphp
@include('back.modules.tableColSorter', [ 'cols' => $cols, 'ignore' => ['tags']])
</thead>
<tbody>
@if($news->count())
@foreach ($news as $newsModel)
<tr>
<td>
<span>{{ $newsModel->title }}</span>
</td>
<td>
@foreach($newsModel->tags as $tag)
<span class="badge bg-primary text-wrap">{{ $tag->name }}</span>
@endforeach
</td>
<td class="text-center">
@switch($newsModel->published)
@case(App\Enums\PublishedStatus::draft())
<span class="badge bg-warning text-wrap">{{ $newsModel->published->label }}</span>
@break
@case(App\Enums\PublishedStatus::published())
<span class="badge bg-success text-wrap">{{ $newsModel->published_at ? __(':status le :date', [
'status' => $newsModel->published->label,
'date' => $newsModel->published_at->isoFormat('DD/MM/YYYY à HH:mm')
]) : '' }}</span>
@break
@case(App\Enums\PublishedStatus::archived())
<span class="badge bg-secondary text-wrap">{{ $newsModel->published->label}}</span>
@break
@endswitch
</td>
<td>
<span class="badge text-dark text-wrap">{{ $newsModel->updated_at_date }}</span>
</td>
<td>
<div class="btn-group">
{{-- Protected Preview Page --}}
@if($newsModel->published->equals(App\Enums\PublishedStatus::draft()))
<a class="btn btn-sm btn-warning" href="" target="_blank" title="{{ __('Prévisualiser :model', [
'model' => Str::singular(__('models.classes.news'))
]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-user-lock"></i></a>
@endif
{{-- Public View Page --}}
@if($newsModel->published->equals(App\Enums\PublishedStatus::published()))
<a class="btn btn-sm btn-warning" href="" target="_blank" title="{{ __('Visualiser :model', [
'model' => Str::singular(__('models.classes.news'))
]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-eye"></i></a>
@endif
<a class="btn btn-sm btn-primary" href="{{ route('bo.news.edit', $newsModel->id) }}" title="{{ __('Modifier :model', [
'model' => Str::singular(__('models.classes.news'))
]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-pencil"></i></a>
</div>
</td>
</tr>
@endforeach
@else
<tr>
<td colspan="5" class="text-center"><span class="h3">{{
__('Aucun résultat pour les :models', ['models' => __('models.classes.news')])
}}</span></td>
</tr>
@endif
</tbody>
</table>
<div class="table-responsive">
<table class="table fixAction">
<thead>
@php
$cols = ['title' => __('Titre'), 'tags' => __('Tags'), 'published' => __('Publication'), 'updated_at' => __('Mis à jour le')];
@endphp
@include('back.modules.tableColSorter', [ 'cols' => $cols, 'ignore' => ['tags']])
</thead>
<tbody>
@if($news->count())
@foreach ($news as $newsModel)
<tr>
<td>
<span>{{ $newsModel->title }}</span>
</td>
<td>
@foreach($newsModel->tags as $tag)
<span class="badge bg-primary text-wrap">{{ $tag->name }}</span>
@endforeach
</td>
<td class="text-center">
@switch($newsModel->published)
@case(App\Enums\PublishedStatus::draft())
<span class="badge bg-warning text-wrap">{{ $newsModel->published->label }}</span>
@break
@case(App\Enums\PublishedStatus::published())
<span class="badge bg-success text-wrap">{{ $newsModel->published_at ? __(':status le :date', [
'status' => $newsModel->published->label,
'date' => $newsModel->published_at->formatLocalized('%d/%m/%Y à %I:%M')
]) : '' }}</span>
@break
@case(App\Enums\PublishedStatus::archived())
<span class="badge bg-secondary text-wrap">{{ $newsModel->published->label}}</span>
@break
@endswitch
</td>
<td>
<span class="badge text-dark text-wrap">{{ $newsModel->updated_at->formatLocalized('%d/%m/%Y à %I:%M') }}</span>
</td>
<td>
<div class="btn-group">
{{-- Protected Preview Page --}}
@if($newsModel->published->equals(App\Enums\PublishedStatus::draft()))
<a class="btn btn-sm btn-warning" href="" target="_blank" title="{{ __('Prévisualiser :model', [
'model' => Str::singular(__('models.classes.news'))
]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-user-lock"></i></a>
@endif
{{-- Public View Page --}}
@if($newsModel->published->equals(App\Enums\PublishedStatus::published()))
<a class="btn btn-sm btn-warning" href="" target="_blank" title="{{ __('Visualiser :model', [
'model' => Str::singular(__('models.classes.news'))
]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-eye"></i></a>
@endif
<a class="btn btn-sm btn-primary" href="{{ route('bo.news.edit', $newsModel->id) }}" title="{{ __('Modifier :model', [
'model' => Str::singular(__('models.classes.news'))
]) }}" data-bs-toggle="tooltip"><i class="fa-solid fa-pencil"></i></a>
</div>
</td>
</tr>
@endforeach
@else
<tr>
<td colspan="5" class="text-center"><span class="h3">{{
__('Aucun résultat pour les :models', ['models' => __('models.classes.news')])
}}</span></td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
</div>
<div class="row justify-content-center">

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