Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions site_novo/app/Filament/Actions/ImportMarkdownAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace App\Filament\Actions;

use App\Support\MarkdownBlocksConverter;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Set;

class ImportMarkdownAction
{
public static function make(string $field = 'content_blocks'): Action
{
return Action::make('importMarkdown')
->label('Importar Markdown')
->icon('heroicon-o-arrow-up-tray')
->color('gray')
->modalHeading('Importar artigo em Markdown')
->modalDescription('Cole o Markdown completo do artigo. Isso substitui todos os blocos de conteúdo abaixo.')
->modalSubmitActionLabel('Converter para blocos')
->schema([
Textarea::make('markdown')
->label('Markdown')
->required()
->rows(18)
->placeholder("## Um título\n\nUm parágrafo com **negrito** e [um link](https://exemplo.com).\n\n![Legenda da imagem](https://exemplo.com/imagem.png)"),
])
->action(function (array $data, Set $set) use ($field) {
$set($field, MarkdownBlocksConverter::toBuilderItems($data['markdown']));

Notification::make()
->title('Markdown convertido em blocos de conteúdo')
->success()
->send();
});
}
}
2 changes: 2 additions & 0 deletions site_novo/app/Filament/Member/Resources/PostResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Filament\Member\Resources;

use App\Enums\PostStatus;
use App\Filament\Actions\ImportMarkdownAction;
use App\Filament\Fields\PostContent;
use App\Filament\Member\Resources\PostResource\Pages;
use App\Models\Post;
Expand Down Expand Up @@ -101,6 +102,7 @@ public static function form(Schema $schema): Schema

Section::make('Conteúdo')->schema([
Actions::make([
ImportMarkdownAction::make('content_blocks'),
InlinePreviewAction::make()
->label('Preview do Conteúdo')
->builderPreview('content_blocks'),
Expand Down
2 changes: 2 additions & 0 deletions site_novo/app/Filament/Resources/PostResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Filament\Resources;

use App\Enums\PostStatus;
use App\Filament\Actions\ImportMarkdownAction;
use App\Filament\Fields\PostContent;
use App\Filament\Resources\PostResource\Pages;
use App\Models\Post;
Expand Down Expand Up @@ -113,6 +114,7 @@ public static function form(Schema $schema): Schema

Section::make('Conteúdo')->schema([
Actions::make([
ImportMarkdownAction::make('content_blocks'),
InlinePreviewAction::make()
->label('Preview do Conteúdo')
->builderPreview('content_blocks'),
Expand Down
138 changes: 138 additions & 0 deletions site_novo/app/Support/MarkdownBlocksConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

namespace App\Support;

use DOMDocument;
use DOMElement;
use Filament\Forms\Components\RichEditor\RichContentRenderer;
use Illuminate\Support\Str;
use League\CommonMark\GithubFlavoredMarkdownConverter;

/**
* Converte markdown colado pelo autor em blocos compatíveis com o Builder
* do Filament (title/paragraph/image) usado no campo content_blocks do
* Post, no mesmo formato que o editor manual de blocos produz.
*/
class MarkdownBlocksConverter
{
/**
* @return array<string, array{type: string, data: array<string, mixed>}>
*/
public static function toBuilderItems(string $markdown): array
{
$items = [];

foreach (static::toBlocks($markdown) as $block) {
$items[(string) Str::uuid()] = $block;
}

return $items;
}

/**
* @return list<array{type: string, data: array<string, mixed>}>
*/
public static function toBlocks(string $markdown): array
{
$html = (new GithubFlavoredMarkdownConverter(['html_input' => 'allow']))->convert($markdown)->getContent();

$dom = new DOMDocument;
libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="utf-8" ?><div>'.$html.'</div>', LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();

$container = $dom->getElementsByTagName('div')->item(0);

if (! $container) {
return [];
}

$blocks = [];

foreach ($container->childNodes as $node) {
if (! $node instanceof DOMElement) {
continue;
}

if (preg_match('/^h[1-6]$/', $node->tagName)) {
$blocks[] = [
'type' => 'title',
'data' => [
'text' => trim($node->textContent),
'level' => self::mapHeadingLevel($node->tagName),
],
];

continue;
}

if ($image = self::extractStandaloneImage($node)) {
$blocks[] = [
'type' => 'image',
'data' => $image,
];

continue;
}

$blocks[] = [
'type' => 'paragraph',
'data' => [
'text' => self::htmlToRichEditorState($dom->saveHTML($node) ?: ''),
],
];
}

return $blocks;
}

/**
* O campo "text" do bloco de parágrafo é um RichEditor do Filament, cujo
* estado em memória (antes de salvar) é sempre um documento TipTap em
* array, não a string HTML final — essa só existe depois que o form é
* salvo (dehydratado). Como escrevemos esse estado diretamente via
* $set(), precisamos produzir o mesmo array que o Filament produziria
* ao carregar o form normalmente, ou o editor quebra ao tentar salvar.
*
* @return array<string, mixed>
*/
private static function htmlToRichEditorState(string $html): array
{
$document = RichContentRenderer::make($html)->getEditor()->getDocument();

return json_decode(json_encode($document), true);
}

private static function mapHeadingLevel(string $tagName): string
{
return match ($tagName) {
'h1', 'h2' => 'h2',
'h3' => 'h3',
default => 'h4',
};
}

/**
* @return array{url: string, alt: string, ratio: string}|null
*/
private static function extractStandaloneImage(DOMElement $node): ?array
{
if ($node->tagName !== 'p') {
return null;
}

$images = $node->getElementsByTagName('img');

if ($images->length !== 1 || trim($node->textContent) !== '') {
return null;
}

$img = $images->item(0);

return [
'url' => $img->getAttribute('src'),
'alt' => $img->getAttribute('alt'),
'ratio' => '16-9',
];
}
}
19 changes: 19 additions & 0 deletions site_novo/resources/views/components/landing/⚡header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ class="sticky top-0 z-50 bg-areia/92 backdrop-blur-md border-b border-azul-marin
Como participar
<span class="absolute left-0 -bottom-1.5 h-0.5 w-0 bg-azul-principal transition-all duration-200 group-hover:w-full"></span>
</a>
<a href="{{ auth()->check() ? route('filament.membro.pages.dashboard') : route('filament.membro.auth.login') }}"
class="inline-flex items-center gap-1.5 text-[12.5px] font-medium text-azul-marinho hover:text-azul-principal transition-colors">
<x-phosphor-user-circle class="w-[18px] h-[18px]" />
@auth
Meu painel
@else
Entrar
@endauth
</a>

<a href="https://chat.whatsapp.com/JaWCta8t2DF9Af0zgIb8LB?mode=gi_t" target="_blank" rel="noopener"
class="inline-flex items-center gap-2 px-[18px] py-[11px] bg-azul-principal hover:bg-[#1779bb] active:scale-[0.98] text-white text-[13px] font-semibold rounded-full transition-all hover:-translate-y-0.5 hover:shadow-[0_8px_20px_rgba(30,142,219,.25)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-azul-claro">
Seja parte
Expand Down Expand Up @@ -76,6 +86,15 @@ class="md:hidden w-11 h-11 -mr-2 flex flex-col items-center justify-center gap-[
<a href="{{ route('blog.index') }}" class="block py-3.5 border-b border-azul-marinho/[0.07] text-[15px] font-medium {{ request()->routeIs('blog.*') ? 'text-azul-principal' : 'text-azul-marinho' }}">Conteúdos</a>
<a href="/#comunidade" class="block py-3.5 border-b border-azul-marinho/[0.07] text-[15px] font-medium text-azul-marinho">Comunidade</a>
<a href="/#participar" class="block py-3.5 border-b border-azul-marinho/[0.07] text-[15px] font-medium text-azul-marinho">Como participar</a>
<a href="{{ auth()->check() ? route('filament.membro.pages.dashboard') : route('filament.membro.auth.login') }}"
class="flex items-center gap-2 py-3.5 text-[15px] font-medium text-azul-marinho">
<x-phosphor-user-circle class="w-5 h-5" />
@auth
Meu painel
@else
Entrar / Criar conta
@endauth
</a>
<a href="https://chat.whatsapp.com/JaWCta8t2DF9Af0zgIb8LB?mode=gi_t" target="_blank" rel="noopener"
class="flex items-center justify-center gap-2 w-full mt-3.5 px-[18px] py-3 bg-azul-principal text-white text-sm font-semibold rounded-full">
Seja parte
Expand Down
21 changes: 19 additions & 2 deletions site_novo/resources/views/components/landing/⚡join.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<x-phosphor-pen-nib class="w-10 h-10" />
</div>
<h4 class="text-[15px] font-semibold text-preto">Contribua com conteúdo</h4>
<p class="mt-1 text-[12.5px] text-cinza">Compartilhe seu conhecimento.</p>
<p class="mt-1 text-[12.5px] text-cinza">Publique seus artigos no painel de membros.</p>
</article>
<article class="text-center px-3.5 py-2.5">
<div class="grid place-items-center w-[86px] h-[86px] mx-auto mb-4 rounded-full bg-white text-azul-marinho shadow-[0_4px_14px_rgba(8,45,82,.08)]">
Expand All @@ -40,7 +40,24 @@
</article>
</div>

<div class="text-center mt-9">
<div class="mt-9 bg-white rounded-2xl p-6 sm:p-7 flex flex-col sm:flex-row items-center gap-5 shadow-[0_4px_14px_rgba(8,45,82,.06)]">
<div class="shrink-0 grid place-items-center w-14 h-14 rounded-full bg-azul-fundo text-azul-principal">
<x-phosphor-squares-four class="w-7 h-7" />
</div>
<div class="flex-1 text-center sm:text-left">
<h4 class="text-[15px] font-semibold text-preto">Temos um painel de membros</h4>
<p class="mt-1 text-[12.5px] text-cinza">
Crie sua conta e publique seus próprios artigos direto pelo site. Mais funcionalidades exclusivas pra membros estão a caminho.
</p>
</div>
<a href="{{ route('filament.membro.auth.register') }}"
class="shrink-0 w-full sm:w-auto justify-center inline-flex items-center gap-2 px-5 py-2.5 rounded-full border-[1.5px] border-azul-principal text-azul-principal font-semibold text-[13px] hover:bg-azul-principal hover:text-white transition-all">
Criar minha conta
<x-phosphor-arrow-right class="w-4 h-4" />
</a>
</div>

<div class="text-center mt-7">
<a href="https://chat.whatsapp.com/JaWCta8t2DF9Af0zgIb8LB?mode=gi_t" target="_blank" rel="noopener"
class="inline-flex items-center gap-2.5 px-[18px] py-[11px] rounded-full bg-azul-principal hover:bg-[#1779bb] text-white font-semibold text-[13px] transition-all hover:-translate-y-0.5 hover:shadow-[0_8px_20px_rgba(30,142,219,.25)]">
Quero participar
Expand Down
25 changes: 25 additions & 0 deletions site_novo/tests/Feature/Filament/ImportMarkdownActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

use App\Models\Category;
use App\Models\Post;
use App\Models\User;

test('the import markdown action is available on the admin post edit page', function () {
$admin = User::factory()->admin()->create();
$post = Post::factory()->create(['category_id' => Category::factory()]);

$this->actingAs($admin, 'web')
->get("/admin/posts/{$post->id}/edit")
->assertOk()
->assertSee('Importar Markdown');
});

test('the import markdown action is available on the member post edit page for the post author', function () {
$author = User::factory()->create();
$post = Post::factory()->create(['author_id' => $author->id, 'category_id' => Category::factory()]);

$this->actingAs($author, 'web')
->get("/membro/posts/{$post->id}/edit")
->assertOk()
->assertSee('Importar Markdown');
});
55 changes: 55 additions & 0 deletions site_novo/tests/Feature/Filament/ImportMarkdownSaveTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

use App\Models\Category;
use App\Models\Post;
use App\Support\MarkdownBlocksConverter;

/**
* Reproduz o fluxo real: converter markdown -> gravar o resultado como
* content_blocks do post -> salvar. Isso é o que estava quebrando com
* "Argument #2 ($rawState) must be of type ?array, string given" porque
* o bloco de parágrafo usa um RichEditor, cujo estado em memória precisa
* ser um documento TipTap (array), não a string HTML final.
*/
test('a post with markdown-imported content blocks can be saved without a RichEditor type error', function () {
$post = Post::factory()->create(['category_id' => Category::factory()]);

$markdown = <<<'MD'
## Boas práticas com PHP 8.3

Um **parágrafo** com `código` e uma lista:

- item um
- item dois

![Capa](https://exemplo.com/capa.png)
MD;

$items = MarkdownBlocksConverter::toBuilderItems($markdown);

// Simula o que o Filament faz ao salvar: percorre o Builder e, para
// cada bloco de parágrafo, dehydrata o estado do RichEditor (array)
// de volta pra HTML — é exatamente esse passo que lançava o TypeError.
$dehydrated = array_map(function (array $item) {
if ($item['type'] !== 'paragraph') {
return $item;
}

$item['data']['text'] = \Filament\Forms\Components\RichEditor\RichContentRenderer::make($item['data']['text'])->toHtml();

return $item;
}, array_values($items));

$post->content_blocks = $dehydrated;
$post->save();

$post->refresh();

expect($post->content_blocks)->toHaveCount(4)
->and($post->content_blocks[0]['type'])->toBe('title')
->and($post->content_blocks[1]['type'])->toBe('paragraph')
->and($post->content_blocks[1]['data']['text'])->toBeString()
->and($post->content_blocks[1]['data']['text'])->toContain('<strong>parágrafo</strong>')
->and($post->content_blocks[2]['type'])->toBe('paragraph')
->and($post->content_blocks[3]['type'])->toBe('image');
});
24 changes: 24 additions & 0 deletions site_novo/tests/Feature/Home/HeaderMemberLinkTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

use App\Models\User;

test('a guest sees a link to login/register in the header', function () {
$this->get(route('home'))
->assertSee('Entrar')
->assertSee(route('filament.membro.auth.login'), false);
});

test('an authenticated user sees a link to their member dashboard in the header', function () {
$user = User::factory()->create();

$this->actingAs($user, 'web')
->get(route('home'))
->assertSee('Meu painel')
->assertSee(route('filament.membro.pages.dashboard'), false);
});

test('the join section mentions the member panel and links to registration', function () {
$this->get(route('home'))
->assertSee('Temos um painel de membros')
->assertSee(route('filament.membro.auth.register'), false);
});
Loading
Loading