diff --git a/site_novo/app/Filament/Actions/ImportMarkdownAction.php b/site_novo/app/Filament/Actions/ImportMarkdownAction.php new file mode 100644 index 0000000..2307801 --- /dev/null +++ b/site_novo/app/Filament/Actions/ImportMarkdownAction.php @@ -0,0 +1,38 @@ +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(); + }); + } +} diff --git a/site_novo/app/Filament/Member/Resources/PostResource.php b/site_novo/app/Filament/Member/Resources/PostResource.php index 62254f7..a9caac4 100644 --- a/site_novo/app/Filament/Member/Resources/PostResource.php +++ b/site_novo/app/Filament/Member/Resources/PostResource.php @@ -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; @@ -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'), diff --git a/site_novo/app/Filament/Resources/PostResource.php b/site_novo/app/Filament/Resources/PostResource.php index 5ac9364..273548a 100644 --- a/site_novo/app/Filament/Resources/PostResource.php +++ b/site_novo/app/Filament/Resources/PostResource.php @@ -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; @@ -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'), diff --git a/site_novo/app/Support/MarkdownBlocksConverter.php b/site_novo/app/Support/MarkdownBlocksConverter.php new file mode 100644 index 0000000..0ad5ad2 --- /dev/null +++ b/site_novo/app/Support/MarkdownBlocksConverter.php @@ -0,0 +1,138 @@ +}> + */ + public static function toBuilderItems(string $markdown): array + { + $items = []; + + foreach (static::toBlocks($markdown) as $block) { + $items[(string) Str::uuid()] = $block; + } + + return $items; + } + + /** + * @return list}> + */ + 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('
'.$html.'
', 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 + */ + 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', + ]; + } +} diff --git "a/site_novo/resources/views/components/landing/\342\232\241header.blade.php" "b/site_novo/resources/views/components/landing/\342\232\241header.blade.php" index cbb03f2..4ff8e17 100644 --- "a/site_novo/resources/views/components/landing/\342\232\241header.blade.php" +++ "b/site_novo/resources/views/components/landing/\342\232\241header.blade.php" @@ -48,6 +48,16 @@ class="sticky top-0 z-50 bg-areia/92 backdrop-blur-md border-b border-azul-marin Como participar + + + @auth + Meu painel + @else + Entrar + @endauth + + Seja parte @@ -76,6 +86,15 @@ class="md:hidden w-11 h-11 -mr-2 flex flex-col items-center justify-center gap-[ Conteúdos Comunidade Como participar + + + @auth + Meu painel + @else + Entrar / Criar conta + @endauth + Seja parte diff --git "a/site_novo/resources/views/components/landing/\342\232\241join.blade.php" "b/site_novo/resources/views/components/landing/\342\232\241join.blade.php" index 6ef1d4b..7a00e24 100644 --- "a/site_novo/resources/views/components/landing/\342\232\241join.blade.php" +++ "b/site_novo/resources/views/components/landing/\342\232\241join.blade.php" @@ -29,7 +29,7 @@

Contribua com conteúdo

-

Compartilhe seu conhecimento.

+

Publique seus artigos no painel de membros.

@@ -40,7 +40,24 @@
-
+ + +
Quero participar diff --git a/site_novo/tests/Feature/Filament/ImportMarkdownActionTest.php b/site_novo/tests/Feature/Filament/ImportMarkdownActionTest.php new file mode 100644 index 0000000..9ea8011 --- /dev/null +++ b/site_novo/tests/Feature/Filament/ImportMarkdownActionTest.php @@ -0,0 +1,25 @@ +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'); +}); diff --git a/site_novo/tests/Feature/Filament/ImportMarkdownSaveTest.php b/site_novo/tests/Feature/Filament/ImportMarkdownSaveTest.php new file mode 100644 index 0000000..372376c --- /dev/null +++ b/site_novo/tests/Feature/Filament/ImportMarkdownSaveTest.php @@ -0,0 +1,55 @@ + 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('parágrafo') + ->and($post->content_blocks[2]['type'])->toBe('paragraph') + ->and($post->content_blocks[3]['type'])->toBe('image'); +}); diff --git a/site_novo/tests/Feature/Home/HeaderMemberLinkTest.php b/site_novo/tests/Feature/Home/HeaderMemberLinkTest.php new file mode 100644 index 0000000..7d65242 --- /dev/null +++ b/site_novo/tests/Feature/Home/HeaderMemberLinkTest.php @@ -0,0 +1,24 @@ +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); +}); diff --git a/site_novo/tests/Feature/Support/MarkdownBlocksConverterTest.php b/site_novo/tests/Feature/Support/MarkdownBlocksConverterTest.php new file mode 100644 index 0000000..2818dac --- /dev/null +++ b/site_novo/tests/Feature/Support/MarkdownBlocksConverterTest.php @@ -0,0 +1,105 @@ +toHtml(); +} + +test('it converts headings into title blocks with clamped levels', function () { + $blocks = MarkdownBlocksConverter::toBlocks("# Nível 1\n\n## Nível 2\n\n### Nível 3\n\n##### Nível 5"); + + expect($blocks)->toHaveCount(4) + ->and($blocks[0])->toBe(['type' => 'title', 'data' => ['text' => 'Nível 1', 'level' => 'h2']]) + ->and($blocks[1])->toBe(['type' => 'title', 'data' => ['text' => 'Nível 2', 'level' => 'h2']]) + ->and($blocks[2])->toBe(['type' => 'title', 'data' => ['text' => 'Nível 3', 'level' => 'h3']]) + ->and($blocks[3])->toBe(['type' => 'title', 'data' => ['text' => 'Nível 5', 'level' => 'h4']]); +}); + +test('it converts a plain paragraph into a paragraph block holding a valid rich editor state', function () { + $blocks = MarkdownBlocksConverter::toBlocks('Um **parágrafo** com `código`.'); + + expect($blocks)->toHaveCount(1) + ->and($blocks[0]['type'])->toBe('paragraph') + ->and($blocks[0]['data']['text'])->toBeArray() + ->and($blocks[0]['data']['text']['type'])->toBe('doc'); + + $html = richEditorStateToHtml($blocks[0]['data']['text']); + + expect($html)->toContain('parágrafo') + ->and($html)->toContain('código'); +}); + +test('it converts a list into a paragraph block preserving the list markup', function () { + $blocks = MarkdownBlocksConverter::toBlocks("- um\n- dois"); + + expect($blocks)->toHaveCount(1) + ->and($blocks[0]['type'])->toBe('paragraph'); + + $html = richEditorStateToHtml($blocks[0]['data']['text']); + + expect($html)->toContain('and($html)->toContain('and($html)->toContain('um') + ->and($html)->toContain('dois'); +}); + +test('a standalone image paragraph becomes its own image block', function () { + $blocks = MarkdownBlocksConverter::toBlocks('![Legenda](https://exemplo.com/foto.png)'); + + expect($blocks)->toHaveCount(1) + ->and($blocks[0])->toBe([ + 'type' => 'image', + 'data' => [ + 'url' => 'https://exemplo.com/foto.png', + 'alt' => 'Legenda', + 'ratio' => '16-9', + ], + ]); +}); + +test('an image inline with text stays inside the paragraph block instead of becoming its own block', function () { + $blocks = MarkdownBlocksConverter::toBlocks('Veja a imagem: ![alt](https://exemplo.com/a.png) ao lado do texto.'); + + expect($blocks)->toHaveCount(1) + ->and($blocks[0]['type'])->toBe('paragraph'); + + expect(richEditorStateToHtml($blocks[0]['data']['text']))->toContain('toHaveCount(2); + + $keys = array_keys($items); + + expect($keys[0])->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/') + ->and($items[$keys[0]]['type'])->toBe('title') + ->and($items[$keys[1]]['type'])->toBe('paragraph'); +}); + +test('a full article converts into a sequence of blocks in document order', function () { + $markdown = <<<'MD' + ## Boas práticas com PHP 8.3 + + Introdução do artigo. + + ![Capa](https://exemplo.com/capa.png) + + ### Tipos nativos + + Mais um parágrafo explicando tipos. + MD; + + $blocks = MarkdownBlocksConverter::toBlocks($markdown); + + expect(array_column($blocks, 'type'))->toBe(['title', 'paragraph', 'image', 'title', 'paragraph']); +});