chore: Update and add various project dependencies.

This commit is contained in:
Paulo Castellano 2026-01-18 16:46:27 -03:00
parent 52650909a4
commit af32361e89
36 changed files with 6386 additions and 29 deletions

View file

@ -14,6 +14,7 @@ ## Foundational Context
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/nightwatch (NIGHTWATCH) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/socialite (SOCIALITE) - v5

View file

@ -14,6 +14,7 @@ ## Foundational Context
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/nightwatch (NIGHTWATCH) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/socialite (SOCIALITE) - v5

View file

@ -4,17 +4,22 @@
use App\Http\Requests\StoreMediaRequest;
use App\Models\Media;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Pion\Laravel\ChunkUpload\Handler\ContentRangeUploadHandler;
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
class MediaController extends Controller
{
public function store(StoreMediaRequest $request): JsonResponse
{
$modelClass = $request->input('model');
$modelAlias = $request->input('model');
$modelId = $request->input('model_id');
$collection = $request->input('collection', 'default');
$modelClass = Relation::getMorphedModel($modelAlias) ?? $modelAlias;
$model = $modelClass::findOrFail($modelId);
$media = $model->addMedia(
@ -30,6 +35,56 @@ public function store(StoreMediaRequest $request): JsonResponse
]);
}
public function storeChunked(Request $request): JsonResponse
{
$receiver = new FileReceiver(
UploadedFile::fake()->createWithContent('file', $request->getContent()),
$request,
ContentRangeUploadHandler::class
);
if (! $receiver->isUploaded()) {
return response()->json(['error' => 'File not uploaded'], 400);
}
$save = $receiver->receive();
if ($save->isFinished()) {
$file = $save->getFile();
$modelAlias = $request->header('X-Model');
$modelId = $request->header('X-Model-Id');
$collection = $request->header('X-Collection', 'default');
$modelClass = Relation::getMorphedModel($modelAlias) ?? $modelAlias;
$model = $modelClass::findOrFail($modelId);
$media = $model->addMediaFromPath(
$file->getRealPath(),
$request->header('X-File-Name', $file->getClientOriginalName()),
$collection
);
// Clean up temp file
unlink($file->getRealPath());
return response()->json([
'done' => true,
'id' => $media->id,
'url' => $media->url,
'type' => $media->type->value,
'original_filename' => $media->original_filename,
]);
}
$handler = $save->handler();
return response()->json([
'done' => false,
'progress' => $handler->getPercentageDone(),
]);
}
public function destroy(string $modelId, Media $media): JsonResponse
{
if ($media->mediable_id !== $modelId) {
@ -48,10 +103,11 @@ public function duplicate(Media $media, Request $request): JsonResponse
$duplicates = [];
foreach ($targets as $target) {
$modelClass = $target['model'];
$modelAlias = $target['model'];
$modelId = $target['model_id'];
$collection = $target['collection'] ?? $media->collection;
$modelClass = Relation::getMorphedModel($modelAlias) ?? $modelAlias;
$model = $modelClass::findOrFail($modelId);
$duplicate = $model->media()->create([

View file

@ -8,6 +8,8 @@
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
trait HasMedia
{
@ -70,8 +72,12 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr
$mimeType = $file->getMimeType();
$type = $this->getMediaType($mimeType);
$extension = $file->getClientOriginalExtension();
$path = $file->store('media/'.now()->format('Y-m'));
$filename = Str::uuid().'.'.$extension;
$path = 'medias/'.$filename;
Storage::put($path, file_get_contents($file->getPathname()));
return $this->media()->create([
'collection' => $collection,
@ -85,6 +91,46 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr
]);
}
/**
* Add media from a file path (used for chunked uploads).
*/
public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = []): Media
{
if ($this->isSingleMediaCollection($collection)) {
$this->clearMediaCollection($collection);
}
$mimeType = mime_content_type($filePath);
$type = $this->getMediaType($mimeType);
$size = filesize($filePath);
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
$filename = Str::uuid().'.'.$extension;
$storagePath = 'medias/'.$filename;
Storage::put($storagePath, file_get_contents($filePath));
$mediaMeta = [];
if ($type === 'image') {
$imageInfo = @getimagesize($filePath);
if ($imageInfo) {
$mediaMeta['width'] = $imageInfo[0];
$mediaMeta['height'] = $imageInfo[1];
}
}
return $this->media()->create([
'collection' => $collection,
'type' => $type,
'path' => $storagePath,
'original_filename' => $originalFilename,
'mime_type' => $mimeType,
'size' => $size,
'order' => 0,
'meta' => array_merge($mediaMeta, $meta),
]);
}
public function clearMediaCollection(string $collection = 'default'): void
{
$this->getMedia($collection)->each(fn (Media $media) => $media->delete());

View file

@ -3,11 +3,26 @@
namespace App\Providers;
use App\Listeners\StripeEventListener;
use App\Models\Language;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Subscription;
use App\Models\SubscriptionItem;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceHashtag;
use App\Models\WorkspaceInvite;
use App\Models\WorkspaceLabel;
use App\Socialite\InstagramProvider;
use App\Socialite\LinkedInPageExtendSocialite;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
@ -15,6 +30,8 @@
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Nightwatch\Facades\Nightwatch;
use Laravel\Nightwatch\Records\CacheEvent;
use Laravel\Socialite\Facades\Socialite;
use SocialiteProviders\Facebook\FacebookExtendSocialite;
use SocialiteProviders\LinkedIn\LinkedInExtendSocialite;
@ -38,6 +55,7 @@ public function register(): void
public function boot(): void
{
$this->configureDefaults();
$this->configureMorphMap();
$this->configureSocialite();
$this->configureStripeWebhooks();
@ -45,6 +63,24 @@ public function boot(): void
Cashier::useSubscriptionItemModel(SubscriptionItem::class);
}
protected function configureMorphMap(): void
{
Relation::enforceMorphMap([
'language' => Language::class,
'media' => Media::class,
'post' => Post::class,
'postPlatform' => PostPlatform::class,
'socialAccount' => SocialAccount::class,
'subscription' => Subscription::class,
'subscriptionItem' => SubscriptionItem::class,
'user' => User::class,
'workspace' => Workspace::class,
'workspaceHashtag' => WorkspaceHashtag::class,
'workspaceInvite' => WorkspaceInvite::class,
'workspaceLabel' => WorkspaceLabel::class,
]);
}
protected function configureStripeWebhooks(): void
{
Event::listen(WebhookReceived::class, StripeEventListener::class);
@ -70,6 +106,9 @@ protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
// Disable wrapping of JSON resources
JsonResource::withoutWrapping();
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
@ -83,5 +122,44 @@ protected function configureDefaults(): void
->uncompromised()
: null
);
Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) {
return in_array($cacheEvent->key, [
'illuminate:foundation:down',
'illuminate:queue:restart',
'illuminate:schedule:interrupt',
]);
});
// Custom email verification template
VerifyEmail::toMailUsing(function (User $user, string $url) {
return (new MailMessage)
->from(config('mail.from.address'), config('mail.from.name'))
->subject('Confirme o seu endereço de e-mail')
->view('mail.email-verification', [
'title' => 'Confirme o seu endereço de e-mail',
'previewText' => 'Por favor, confirme o seu endereço de e-mail.',
'user' => $user,
'url' => $url,
]);
});
// Custom password reset template
ResetPassword::toMailUsing(function (User $user, string $token) {
$url = url(route('password.reset', [
'token' => $token,
'email' => $user->getEmailForPasswordReset(),
], false));
return (new MailMessage)
->from(config('mail.from.address'), config('mail.from.name'))
->subject('Redefina sua senha')
->view('mail.password-reset', [
'title' => 'Redefina sua senha',
'previewText' => 'Por favor, redefina sua senha.',
'user' => $user,
'url' => $url,
]);
});
}
}

View file

@ -29,7 +29,7 @@ class FacebookPublisher
467, // Invalid access token
];
private string $baseUrl = 'https://graph.facebook.com/v21.0';
private string $baseUrl = 'https://graph.facebook.com/v24.0';
public function publish(PostPlatform $postPlatform): array
{

View file

@ -29,7 +29,7 @@ class InstagramPublisher
467, // Invalid access token
];
private string $baseUrl = 'https://graph.facebook.com/v21.0';
private string $baseUrl = 'https://graph.instagram.com/v24.0';
public function publish(PostPlatform $postPlatform): array
{
@ -58,7 +58,7 @@ public function publish(PostPlatform $postPlatform): array
private function publishSingleImage(string $instagramId, string $accessToken, string $content, $media): array
{
Log::info('Instagram publishing single image', ['instagram_id' => $instagramId]);
Log::info('Instagram publishing single image', ['instagram_id' => $instagramId, 'image_url' => $media->url]);
// Step 1: Create container
$containerResponse = Http::post("{$this->baseUrl}/{$instagramId}/media", [
@ -67,6 +67,11 @@ private function publishSingleImage(string $instagramId, string $accessToken, st
'access_token' => $accessToken,
]);
Log::info('Instagram container response', [
'status' => $containerResponse->status(),
'body' => $containerResponse->json(),
]);
if ($containerResponse->failed()) {
Log::error('Instagram container creation failed', [
'status' => $containerResponse->status(),
@ -75,9 +80,16 @@ private function publishSingleImage(string $instagramId, string $accessToken, st
$this->handleApiError($containerResponse, 'Instagram API error');
}
$containerId = $containerResponse->json()['id'];
$containerId = $containerResponse->json()['id'] ?? null;
// Step 2: Publish container
if (! $containerId) {
throw new \Exception('Instagram container creation failed: No container ID returned');
}
// Step 2: Wait for container to be ready
$this->waitForMediaProcessing($containerId, $accessToken);
// Step 3: Publish container
return $this->publishContainer($instagramId, $accessToken, $containerId);
}
@ -101,7 +113,11 @@ private function publishReel(string $instagramId, string $accessToken, string $c
$this->handleApiError($containerResponse, 'Instagram API error');
}
$containerId = $containerResponse->json()['id'];
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Instagram reel container creation failed: No container ID returned');
}
// Wait for video processing
$this->waitForMediaProcessing($containerId, $accessToken);
@ -138,14 +154,16 @@ private function publishStory(string $instagramId, string $accessToken, $media):
$this->handleApiError($containerResponse, 'Instagram API error');
}
$containerId = $containerResponse->json()['id'];
$containerId = $containerResponse->json()['id'] ?? null;
// Wait for video processing if needed
if ($isVideo) {
$this->waitForMediaProcessing($containerId, $accessToken);
if (! $containerId) {
throw new \Exception('Instagram story container creation failed: No container ID returned');
}
// Step 2: Publish story container
// Step 2: Wait for media processing
$this->waitForMediaProcessing($containerId, $accessToken);
// Step 3: Publish story container
return $this->publishContainer($instagramId, $accessToken, $containerId);
}
@ -213,9 +231,16 @@ private function publishCarousel(string $instagramId, string $accessToken, strin
$this->handleApiError($carouselResponse, 'Instagram API error');
}
$carouselId = $carouselResponse->json()['id'];
$carouselId = $carouselResponse->json()['id'] ?? null;
// Step 3: Publish carousel
if (! $carouselId) {
throw new \Exception('Instagram carousel container creation failed: No container ID returned');
}
// Step 3: Wait for carousel to be ready
$this->waitForMediaProcessing($carouselId, $accessToken);
// Step 4: Publish carousel
return $this->publishContainer($instagramId, $accessToken, $carouselId);
}

View file

@ -15,11 +15,13 @@
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.42",
"laravel/nightwatch": "^1.22",
"laravel/reverb": "^1.0",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9",
"league/flysystem-aws-s3-v3": "^3.0",
"pion/laravel-chunk-upload": "^1.5",
"predis/predis": "^3.3",
"socialiteproviders/facebook": "^4.1",
"socialiteproviders/instagram": "^5.1",

162
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "162908adce6139201ce70a7e1c4db434",
"content-hash": "6afc160a26be910e0994d4a9ceea451c",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2070,6 +2070,100 @@
},
"time": "2026-01-06T14:49:58+00:00"
},
{
"name": "laravel/nightwatch",
"version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/nightwatch.git",
"reference": "a6ef3f6bccc81e69e17e4f67992c1a3ab6a85110"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/nightwatch/zipball/a6ef3f6bccc81e69e17e4f67992c1a3ab6a85110",
"reference": "a6ef3f6bccc81e69e17e4f67992c1a3ab6a85110",
"shasum": ""
},
"require": {
"ext-zlib": "*",
"guzzlehttp/promises": "^2.0",
"laravel/framework": "^10.0|^11.0|^12.0",
"monolog/monolog": "^3.6",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.2",
"psr/http-message": "^1.0|^2.0",
"psr/log": "^1.0|^2.0|^3.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0",
"symfony/http-foundation": "^6.0|^7.0",
"symfony/polyfill-php84": "^1.29"
},
"require-dev": {
"aws/aws-sdk-php": "^3.349",
"ext-pcntl": "*",
"ext-pdo": "*",
"guzzlehttp/guzzle": "^7.0",
"guzzlehttp/psr7": "^2.0",
"laravel/horizon": "^5.4",
"laravel/pint": "1.21.0",
"laravel/vapor-core": "^2.38.2",
"livewire/livewire": "^2.0|^3.0",
"mockery/mockery": "^1.0",
"mongodb/laravel-mongodb": "^4.0|^5.0",
"orchestra/testbench": "^8.0|^9.0|^10.0",
"orchestra/testbench-core": "^8.0|^9.0|^10.0",
"orchestra/workbench": "^8.0|^9.0|^10.0",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^10.0|^11.0|^12.0",
"singlestoredb/singlestoredb-laravel": "^1.0|^2.0",
"spatie/laravel-ignition": "^2.0",
"symfony/mailer": "^6.0|^7.0",
"symfony/mime": "^6.0|^7.0",
"symfony/var-dumper": "^6.0|^7.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Nightwatch": "Laravel\\Nightwatch\\Facades\\Nightwatch"
},
"providers": [
"Laravel\\Nightwatch\\NightwatchServiceProvider"
]
}
},
"autoload": {
"files": [
"agent/helpers.php"
],
"psr-4": {
"Laravel\\Nightwatch\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The official Laravel Nightwatch package.",
"homepage": "https://nightwatch.laravel.com",
"keywords": [
"Insights",
"laravel",
"monitoring"
],
"support": {
"docs": "https://nightwatch.laravel.com/docs",
"issues": "https://github.com/laravel/nightwatch/issues",
"source": "https://github.com/laravel/nightwatch"
},
"time": "2026-01-15T04:53:20+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.3.9",
@ -4273,6 +4367,72 @@
},
"time": "2026-01-12T11:33:04+00:00"
},
{
"name": "pion/laravel-chunk-upload",
"version": "v1.5.6",
"source": {
"type": "git",
"url": "https://github.com/pionl/laravel-chunk-upload.git",
"reference": "5cfdb8d9058bb4ecdf3a3100b6c7bb197c21e4d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pionl/laravel-chunk-upload/zipball/5cfdb8d9058bb4ecdf3a3100b6c7bb197c21e4d4",
"reference": "5cfdb8d9058bb4ecdf3a3100b6c7bb197c21e4d4",
"shasum": ""
},
"require": {
"illuminate/console": "5.2 - 5.8 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
"illuminate/filesystem": "5.2 - 5.8 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
"illuminate/http": "5.2 - 5.8 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
"illuminate/support": "5.2 - 5.8 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.16.0 | ^3.52.0",
"mockery/mockery": "^1.1.0 | ^1.3.0 | ^1.6.0",
"overtrue/phplint": "^1.1 | ^2.0 | ^9.1",
"phpunit/phpunit": "5.7 | 6.0 | 7.0 | 7.5 | 8.4 | ^8.5 | ^9.3 | ^10.0 | ^11.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Pion\\Laravel\\ChunkUpload\\Providers\\ChunkUploadServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Pion\\Laravel\\ChunkUpload\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Martin Kluska",
"email": "martin@kluska.cz"
}
],
"description": "Service for chunked upload with several js providers",
"support": {
"issues": "https://github.com/pionl/laravel-chunk-upload/issues",
"source": "https://github.com/pionl/laravel-chunk-upload/tree/v1.5.6"
},
"funding": [
{
"url": "https://revolut.me/martinpv7n",
"type": "custom"
},
{
"url": "https://github.com/pionl",
"type": "github"
}
],
"time": "2025-03-19T16:30:08+00:00"
},
{
"name": "pragmarx/google2fa",
"version": "v9.0.0",

45
config/chunk-upload.php Normal file
View file

@ -0,0 +1,45 @@
<?php
/**
* @see https://github.com/pionl/laravel-chunk-upload
*/
return [
/*
* The storage config
*/
'storage' => [
/*
* Returns the folder name of the chunks. The location is in storage/app/{folder_name}
*/
'chunks' => 'chunks',
'disk' => 'local',
],
'clear' => [
/*
* How old chunks we should delete
*/
'timestamp' => '-3 HOURS',
'schedule' => [
'enabled' => true,
'cron' => '25 * * * *', // run every hour on the 25th minute
],
],
'chunk' => [
// setup for the chunk naming setup to ensure same name upload at same time
'name' => [
'use' => [
'session' => true, // should the chunk name use the session id? The uploader must send cookie!,
'browser' => false, // instead of session we can use the ip and browser?
],
],
],
'handlers' => [
// A list of handlers/providers that will be appended to existing list of handlers
'custom' => [],
// Overrides the list of handlers - use only what you really want
'override' => [
// \Pion\Laravel\ChunkUpload\Handler\DropZoneUploadHandler::class
],
],
];

File diff suppressed because one or more lines are too long

9
maizzle/.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

3
maizzle/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.idea
.vscode
node_modules

48
maizzle/README.md Normal file
View file

@ -0,0 +1,48 @@
<div align="center">
<p>
<a href="https://maizzle.com" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/maizzle/maizzle/raw/master/.github/logo-dark.svg">
<img alt="Maizzle Starter" src="https://github.com/maizzle/maizzle/raw/master/.github/logo-light.svg" width="300" height="225" style="max-width: 100%;">
</picture>
</a>
</p>
<p>Quickly build HTML emails with Tailwind CSS</p>
<div>
[![Version][npm-version-shield]][npm]
[![Build][github-ci-shield]][github-ci]
[![Downloads][npm-stats-shield]][npm-stats]
[![License][license-shield]][license]
</div>
</div>
## Getting Started
Run this command and follow the prompts:
```bash
npx create-maizzle
```
## Documentation
Maizzle documentation is available at https://maizzle.com
## Issues
Please open all issues in the [framework repository](https://github.com/maizzle/framework).
## License
The Maizzle framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
[npm]: https://www.npmjs.com/package/@maizzle/framework
[npm-stats]: https://npm-stat.com/charts.html?package=%40maizzle%2Fframework&from=2019-03-27
[npm-version-shield]: https://img.shields.io/npm/v/@maizzle/framework.svg
[npm-stats-shield]: https://img.shields.io/npm/dt/@maizzle/framework.svg?color=6875f5
[github-ci]: https://github.com/maizzle/framework/actions
[github-ci-shield]: https://github.com/maizzle/framework/actions/workflows/nodejs.yml/badge.svg
[license]: ./LICENSE
[license-shield]: https://img.shields.io/npm/l/@maizzle/framework.svg?color=0e9f6e

View file

@ -0,0 +1,53 @@
<script props>
// https://maizzle.com/docs/components/button
let align = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
}[props.align] || ''
let styles = [
'display: inline-block;',
'text-decoration: none;',
'padding: 16px 24px;',
'font-size: 16px;',
'line-height: 1;',
'border-radius: 8px;',
]
let hasBgClass = () => props.class && props.class.split(' ').some(c => c.startsWith('bg-'))
if (props['bg-color'] && !hasBgClass()) {
styles.push(`background-color: ${props['bg-color']};`)
} else {
styles.push('background-color: #262626;')
}
if (props.color) {
styles.push(`color: ${props.color};`)
} else {
styles.push('color: #ffffff;')
}
module.exports = {
align,
href: props.href,
styles: styles.join(''),
msoPt: props['mso-pt'] || '16px',
msoPb: props['mso-pb'] || '31px',
}
</script>
<div class="{{ align }}">
<a attributes href="{{ href }}" style="{{ styles }}">
<outlook trim>
<i class="mso-font-width-[150%]" style="mso-text-raise: {{ msoPb }};" hidden>&emsp;</i>
</outlook>
<span style="mso-text-raise: {{ msoPt }}" trim>
<yield />
</span>
<outlook trim>
<i class="mso-font-width-[150%]" hidden>&emsp;&#8203;</i>
</outlook>
</a>
</div>

View file

@ -0,0 +1,70 @@
<script props>
// https://maizzle.com/docs/components/divider
let styles = [
`height: ${props.height || '1px'};`,
`line-height: ${props.height || '1px'};`,
]
/**
* Color
*
* If a Tailwind background color class was passed, use it.
* Otherwise, the `color` prop will take precedence if
* as long as it was passed.
*/
let hasBgClass = () => props.class && props.class.split(' ').some(c => c.startsWith('bg-'))
if (props.color) {
styles.push(`background-color: ${props.color};`)
}
if (!props.color && !hasBgClass()) {
styles.push(`background-color: #cbd5e1;`)
}
/**
* Margins
*
* If any margin prop was passed, add `margin: 0` first.
* It's important that this comes first, so inlining
* does not use it to override existing margins.
*/
if (props.top || props.bottom || props.left || props.right || props['space-y'] || props['space-x']) {
styles.push('margin: 0;')
}
props['space-y'] = props['space-y'] === 0 ? '0px' : props['space-y'] || '24px'
if (props['space-y']) {
styles.push(`margin-top: ${props['space-y']}; margin-bottom: ${props['space-y']};`)
}
props['space-x'] = props['space-x'] === 0 ? '0px' : props['space-x']
if (props['space-x']) {
styles.push(`margin-left: ${props['space-x']}; margin-right: ${props['space-x']};`)
}
props.top = props.top === 0 ? '0px' : props.top
if (props.top) {
styles.push(`margin-top: ${props.top};`)
}
props.bottom = props.bottom === 0 ? '0px' : props.bottom
if (props.bottom) {
styles.push(`margin-bottom: ${props.bottom};`)
}
props.left = props.left === 0 ? '0px' : props.left
if (props.left) {
styles.push(`margin-left: ${props.left};`)
}
props.right = props.right === 0 ? '0px' : props.right
if (props.right) {
styles.push(`margin-right: ${props.right};`)
}
module.exports = {
styles: styles.join(''),
}
</script>
<div role="separator" style="{{ styles }}">&zwj;</div>

View file

@ -0,0 +1,44 @@
<script props>
module.exports = {
unsubscribe_url: props.unsubscribe_url
}
</script>
<tr>
<td align="center" class="text-center text-zinc-600 text-xs p-6">
<p class="m-0 mb-2">
Enviado por <a href="https://clinyx.com.br" class="text-zinc-600">Clinyx</a> - Sistema para gestão de clínicas e consultórios
</p>
<p class="m-0 cursor-default">
<a href="https://www.linkedin.com/company/clinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
LinkedIn
</a>
&bull;
<a href="https://www.facebook.com/clinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
Facebook
</a>
&bull;
<a href="https://instagram.com/useclinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
Instagram
</a>
&bull;
<a href="https://youtube.com/@useclinyx" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
YouTube
</a>
</p>
@if(isset($unsubscribe_url))
<p class="m-0 mt-2">
<a href="{{ unsubscribe_url }}" target="_blank"
class="text-zinc-600 [text-decoration:none] hover:![text-decoration:underline]">
Unsubscribe
</a>
</p>
@endif
</td>
</tr>

View file

@ -0,0 +1,5 @@
<div class="my-12 sm:my-8 text-center">
<a href="https://trypost.it" target="_blank">
<img src="@{{ asset('/images/emails/trypost/logo-header.png') }}" width="160" alt="Trypost">
</a>
</div>

View file

@ -0,0 +1,24 @@
<script props>
// https://maizzle.com/docs/components/spacer
let styles = []
if (props.height) {
styles.push(`line-height: ${props.height};`)
}
if (props['mso-height']) {
styles.push(`mso-line-height-alt: ${props['mso-height']};`)
}
module.exports = {
height: props.height,
styles: styles.join('')
}
</script>
<if condition="height">
<div role="separator" style="{{ styles }}">&zwj;</div>
</if>
<else>
<div role="separator">&zwj;</div>
</else>

View file

@ -0,0 +1,25 @@
<script props>
// https://maizzle.com/docs/components/vml#v-fill
module.exports = {
width: props.width || '600px',
type: props.type || 'frame',
sizes: props.sizes,
origin: props.origin,
position: props.position,
aspect: props.aspect,
color: props.color,
inset: props.inset || '0,0,0,0',
stroke: props.stroke || 'f',
strokecolor: props.strokecolor,
fill: props.fill || 't',
fillcolor: props.fillcolor || 'none',
image: props.image || 'https://via.placeholder.com/600x400'
}
</script>
<!--[if mso]>
<v:rect fill="{{ fillcolor ? 't' : fill }}" stroke="{{ strokecolor ? 't' : stroke }}" style="width: {{ width }}" xmlns:v="urn:schemas-microsoft-com:vml"{{{ strokecolor ? ` strokecolor="${strokecolor}"` : '' }}}{{{ fillcolor ? ` fillcolor="${fillcolor}"` : '' }}}>
<v:fill type="{{ type }}" src="{{{ image }}}"{{{ sizes ? ` sizes="${sizes}"` : '' }}}{{{ aspect ? ` aspect="${aspect}"` : '' }}}{{{ origin ? ` origin="${origin}"` : '' }}}{{{ position ? ` position="${position}"` : '' }}}{{{ color ? ` color="${color}"` : '' }}} />
<v:textbox inset="{{ inset }}" style="mso-fit-shape-to-text: true"><div><![endif]-->
<yield />
<!--[if mso]></div></v:textbox></v:rect><![endif]-->

View file

@ -0,0 +1,14 @@
<script props>
module.exports = {
width: props.width || '600px',
height: props.height || '400px',
image: props.image || 'https://placehold.co/600x400'
}
</script>
<!--[if mso]>
<v:image src="{!! image !!}" style="width: {{ width }}; height: {{ height }};" xmlns:v="urn:schemas-microsoft-com:vml" />
<v:rect fill="f" stroke="f" style="position: absolute; width: {{ width }}; height: {{ height }};" xmlns:v="urn:schemas-microsoft-com:vml">
<v:textbox inset="0,0,0,0"><div><![endif]-->
<yield />
<!--[if mso]></div></v:textbox></v:rect><![endif]-->

18
maizzle/config.js Normal file
View file

@ -0,0 +1,18 @@
/** @type {import('@maizzle/framework').Config} */
export default {
build: {
content: ['templates/**/*.html'],
static: {
source: ['images/**/*.*'],
destination: 'images',
},
output: {
path: 'build_local',
},
},
posthtml: {
expressions: {
unescapeDelimiters: ['{!!', '!!}'],
},
},
};

View file

@ -0,0 +1,26 @@
/** @type {import('@maizzle/framework').Config} */
export default {
build: {
content: ['templates/**/*.html'],
static: {
source: ['images/**/*.*'],
destination: '../../../public/images/emails',
},
output: {
path: '../resources/views/mail',
extension: 'blade.php',
from: 'templates',
},
},
posthtml: {
expressions: {
unescapeDelimiters: ['{!!', '!!}'],
},
},
css: {
inline: true,
purge: true,
shorthand: true,
},
prettify: true,
};

BIN
maizzle/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

53
maizzle/layouts/main.html Normal file
View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="pt-BR" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
.mso-break-all {word-break: break-all;}
</style>
<![endif]-->
@if(isset($title))
<title>@{{ $title }}</title>
@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
<style>
@tailwind components;
@tailwind utilities;
img {
@apply max-w-full align-middle;
}
</style>
<stack name="head" />
</head>
<body class="m-0 p-0 w-full [word-break:break-word] [-webkit-font-smoothing:antialiased] {{ page.bodyClass || '' }}">
@if(isset($preheader))
<div class="hidden">
@{{ $preheader }}
<each loop="item in Array.from(Array(150))">&#8199;&#65279;&#847; </each>
</div>
@endif
<div role="article" aria-roledescription="email" aria-label="@{{ $title }}" lang="pt-BR">
<yield />
</div>
</body>
</html>

5262
maizzle/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

12
maizzle/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "maizzle serve",
"build": "maizzle build production"
},
"dependencies": {
"@maizzle/framework": "latest",
"tailwindcss-preset-email": "latest"
}
}

View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
presets: [
require('tailwindcss-preset-email'),
],
content: [
'./components/**/*.html',
'./templates/**/*.html',
'./layouts/**/*.html',
],
}

View file

@ -0,0 +1,44 @@
<x-main>
<div class="bg-zinc-50 sm:px-4 font-sans">
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<x-header />
<table class="w-full">
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-800 bg-white rounded shadow-sm">
<p class="leading-6 mt-0">
Olá @{{$user->name}},
</p>
<p class="leading-6">
Por favor, confirme seu endereço de e-mail clicando no botão abaixo:
</p>
<x-spacer height="24px" />
<div class="text-center">
<x-button href="@{{ $url }}">
Confirmar Endereço de E-mail &rarr;
</x-button>
</div>
<x-spacer height="24px" />
<p class="leading-6">
Se você não criou esta conta, pode ignorar este e-mail com segurança.
</p>
<p class="leading-6 mb-0">
Atenciosamente,
<br />
Equipe Clinyx
</p>
</td>
</tr>
</table>
<x-footer />
</td>
</tr>
</table>
</div>
</x-main>

View file

@ -0,0 +1,41 @@
---
bodyClass: bg-slate-50
preheader: Convite para o sistema da Clinyx
---
<x-main>
<div class="bg-zinc-50 sm:px-4 font-sans">
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<x-header />
<table class="w-full">
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
Olá 👋
</h1>
<p class="m-0 leading-6">
Você foi convidado para acessar o sistema da Clinyx.
<br />
<br />
Para aceitar o convite e começar a utilizar a plataforma, clique no botão abaixo.
</p>
<x-spacer height="24px" />
<div class="flex items-center justify-center">
<x-button href="@{{ $url }}">
Aceitar convite &rarr;
</x-button>
</div>
</td>
</tr>
</table>
<x-footer />
</td>
</tr>
</table>
</div>
</x-main>

View file

@ -0,0 +1,44 @@
<x-main>
<div class="bg-zinc-50 sm:px-4 font-sans">
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<x-header />
<table class="w-full">
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-800 bg-white rounded shadow-sm">
<p class="leading-6 mt-0">
Olá @{{$user->name}},
</p>
<p class="leading-6">
Recebemos uma solicitação para redefinir a senha da sua conta. Clique no botão abaixo para criar uma nova senha:
</p>
<x-spacer height="24px" />
<div class="text-center">
<x-button href="@{{ $url }}">
Redefinir Senha &rarr;
</x-button>
</div>
<x-spacer height="24px" />
<p class="leading-6">
Este link expira em 60 minutos. Se você não solicitou a redefinição de senha, pode ignorar este e-mail com segurança.
</p>
<p class="leading-6 mb-0">
Atenciosamente,
<br />
Equipe Clinyx
</p>
</td>
</tr>
</table>
<x-footer />
</td>
</tr>
</table>
</div>
</x-main>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -11,6 +11,13 @@ import {
IconBookmark,
IconMusic,
} from '@tabler/icons-vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface SocialAccount {
id: string;
@ -27,11 +34,18 @@ interface MediaItem {
original_filename: string;
}
interface ContentTypeOption {
value: string;
label: string;
description: string;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
contentType?: string;
contentTypeOptions?: ContentTypeOption[];
charCount: number;
maxLength: number;
isValid: boolean;
@ -45,17 +59,42 @@ const props = defineProps<Props>();
const isReel = computed(() => props.contentType === 'instagram_reel');
const isStory = computed(() => props.contentType === 'instagram_story');
const isFeed = computed(() => props.contentType === 'instagram_feed' || !props.contentType);
const hasMultipleContentTypes = computed(() => (props.contentTypeOptions?.length || 0) > 1);
const emit = defineEmits<{
'update:content': [value: string];
'update:contentType': [value: string];
'upload': [event: Event];
'remove-media': [mediaId: string];
}>();
</script>
<template>
<!-- Reel/Story Preview (vertical) -->
<div v-if="isReel || isStory" class="mx-auto" style="max-width: 320px;">
<div class="space-y-4">
<!-- Content Type Selector -->
<div v-if="hasMultipleContentTypes" class="flex items-center justify-center gap-2">
<span class="text-sm text-muted-foreground">Type:</span>
<Select
:model-value="contentType"
@update:model-value="emit('update:contentType', $event)"
>
<SelectTrigger class="w-[140px] h-8">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in contentTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Reel/Story Preview (vertical) -->
<div v-if="isReel || isStory" class="mx-auto" style="max-width: 320px;">
<div class="relative bg-black rounded-2xl overflow-hidden" style="aspect-ratio: 9/16;">
<!-- Media Area -->
<div v-if="media.length > 0" class="w-full h-full">
@ -367,4 +406,5 @@ const emit = defineEmits<{
</label>
</div>
</div>
</div>
</template>

View file

@ -42,7 +42,8 @@ import DatePicker from '@/components/DatePicker.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import { calendar } from '@/routes';
import { destroy as destroyPost, update as updatePost } from '@/routes/posts';
import { store as storeMedia, destroy as destroyMedia, duplicate as duplicateMedia } from '@/routes/medias';
import { store as storeMedia, storeChunked as storeMediaChunked, destroy as destroyMedia, duplicate as duplicateMedia } from '@/actions/App/Http/Controllers/MediaController';
import { uploadChunked, shouldUseChunkedUpload } from '@/utils/chunkedUpload';
import { type BreadcrumbItemType } from '@/types';
interface SocialAccount {
@ -517,14 +518,30 @@ const handleFileUpload = async (event: Event, postPlatformId: string) => {
for (const file of Array.from(files)) {
try {
// 1. Upload once to the current platform
const formData = new FormData();
formData.append('media', file);
formData.append('model', 'App\\Models\\PostPlatform');
formData.append('model_id', postPlatformId);
let data;
const response = await axios.post(storeMedia.url(), formData);
const data = response.data;
// Use chunked upload for large files (> 10MB)
if (shouldUseChunkedUpload(file)) {
data = await uploadChunked({
file,
url: storeMediaChunked.url(),
model: 'postPlatform',
modelId: postPlatformId,
collection: 'default',
onProgress: (progress) => {
console.log(`Upload progress: ${progress}%`);
},
});
} else {
// Regular upload for small files
const formData = new FormData();
formData.append('media', file);
formData.append('model', 'postPlatform');
formData.append('model_id', postPlatformId);
const response = await axios.post(storeMedia.url(), formData);
data = response.data;
}
// Add to current platform (use spread for reactivity)
const currentMedia = platformMedia.value[postPlatformId] || [];
@ -533,7 +550,7 @@ const handleFileUpload = async (event: Event, postPlatformId: string) => {
// 2. If synced, duplicate to other platforms
if (otherPlatformIds.length > 0) {
const targets = otherPlatformIds.map(id => ({
model: 'App\\Models\\PostPlatform',
model: 'postPlatform',
model_id: id,
}));

View file

@ -0,0 +1,80 @@
import axios from 'axios';
interface ChunkedUploadOptions {
file: File;
url: string;
model: string;
modelId: string;
collection?: string;
chunkSize?: number;
onProgress?: (progress: number) => void;
onComplete?: (response: any) => void;
onError?: (error: any) => void;
}
interface ChunkedUploadResult {
id: string;
url: string;
type: string;
original_filename: string;
}
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
export async function uploadChunked(options: ChunkedUploadOptions): Promise<ChunkedUploadResult> {
const {
file,
url,
model,
modelId,
collection = 'default',
chunkSize = DEFAULT_CHUNK_SIZE,
onProgress,
onComplete,
onError,
} = options;
const totalSize = file.size;
const totalChunks = Math.ceil(totalSize / chunkSize);
let uploadedBytes = 0;
try {
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, totalSize);
const chunk = file.slice(start, end);
const response = await axios.post(url, chunk, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`,
'X-Model': model,
'X-Model-Id': modelId,
'X-Collection': collection,
'X-File-Name': file.name,
},
});
uploadedBytes = end;
const progress = Math.round((uploadedBytes / totalSize) * 100);
onProgress?.(progress);
if (response.data.done) {
onComplete?.(response.data);
return response.data;
}
}
throw new Error('Upload did not complete');
} catch (error) {
onError?.(error);
throw error;
}
}
// Threshold for when to use chunked upload (10MB)
const CHUNKED_UPLOAD_THRESHOLD = 10 * 1024 * 1024;
export function shouldUseChunkedUpload(file: File): boolean {
return file.size > CHUNKED_UPLOAD_THRESHOLD;
}

View file

@ -131,6 +131,7 @@
// Media
Route::post('medias', [MediaController::class, 'store'])->name('medias.store');
Route::post('medias/chunked', [MediaController::class, 'storeChunked'])->name('medias.store-chunked');
Route::post('medias/{media}/duplicate', [MediaController::class, 'duplicate'])->name('medias.duplicate');
Route::delete('medias/{modelId}/{media}', [MediaController::class, 'destroy'])->name('medias.destroy');