libra/docs/stories/story-5.1-post-creation-edi...

7.0 KiB

Story 5.1: Post Creation & Editing

Epic Reference

Epic 5: Posts/Blog System

User Story

As an admin, I want to create and edit blog posts with rich text formatting, So that I can publish professional legal content for website visitors.

Story Context

Existing System Integration

  • Integrates with: posts table
  • Technology: Livewire Volt, TinyMCE or similar rich text editor
  • Follows pattern: Admin CRUD pattern
  • Touch points: Public posts display

Acceptance Criteria

Post Creation Form

  • Title (required, bilingual: Arabic and English)
  • Body content (required, bilingual)
  • Status (draft/published)

Rich Text Editor

  • Bold, italic, underline
  • Headings (H2, H3)
  • Bullet and numbered lists
  • Links
  • Blockquotes

Saving Features

  • Save as draft functionality
  • Preview post before publishing
  • Edit published posts
  • Auto-save draft periodically (every 60 seconds)
  • Immediate publishing (no scheduling)

Timestamps

  • created_at recorded on creation
  • updated_at updated on edit

Quality Requirements

  • HTML sanitization for XSS prevention
  • Bilingual form labels
  • Audit log for create/edit
  • Tests for CRUD operations

Technical Notes

Database Schema

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title_ar');
    $table->string('title_en');
    $table->text('body_ar');
    $table->text('body_en');
    $table->enum('status', ['draft', 'published'])->default('draft');
    $table->timestamps();
});

Post Model

class Post extends Model
{
    protected $fillable = [
        'title_ar', 'title_en', 'body_ar', 'body_en', 'status',
    ];

    public function getTitleAttribute(): string
    {
        $locale = app()->getLocale();
        return $this->{"title_{$locale}"} ?? $this->title_en;
    }

    public function getBodyAttribute(): string
    {
        $locale = app()->getLocale();
        return $this->{"body_{$locale}"} ?? $this->body_en;
    }

    public function getExcerptAttribute(): string
    {
        return Str::limit(strip_tags($this->body), 150);
    }

    public function scopePublished($query)
    {
        return $query->where('status', 'published');
    }

    public function scopeDraft($query)
    {
        return $query->where('status', 'draft');
    }
}

Volt Component

<?php

use App\Models\Post;
use Livewire\Volt\Component;

new class extends Component {
    public ?Post $post = null;

    public string $title_ar = '';
    public string $title_en = '';
    public string $body_ar = '';
    public string $body_en = '';
    public string $status = 'draft';

    public function mount(?Post $post = null): void
    {
        if ($post?->exists) {
            $this->post = $post;
            $this->fill($post->only([
                'title_ar', 'title_en', 'body_ar', 'body_en', 'status'
            ]));
        }
    }

    public function save(): void
    {
        $validated = $this->validate([
            'title_ar' => ['required', 'string', 'max:255'],
            'title_en' => ['required', 'string', 'max:255'],
            'body_ar' => ['required', 'string'],
            'body_en' => ['required', 'string'],
            'status' => ['required', 'in:draft,published'],
        ]);

        // Sanitize HTML
        $validated['body_ar'] = clean($validated['body_ar']);
        $validated['body_en'] = clean($validated['body_en']);

        if ($this->post) {
            $this->post->update($validated);
            $action = 'update';
        } else {
            $this->post = Post::create($validated);
            $action = 'create';
        }

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => $action,
            'target_type' => 'post',
            'target_id' => $this->post->id,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.post_saved'));
    }

    public function saveDraft(): void
    {
        $this->status = 'draft';
        $this->save();
    }

    public function publish(): void
    {
        $this->status = 'published';
        $this->save();
    }

    public function autoSave(): void
    {
        if ($this->post && $this->status === 'draft') {
            $this->post->update([
                'title_ar' => $this->title_ar,
                'title_en' => $this->title_en,
                'body_ar' => clean($this->body_ar),
                'body_en' => clean($this->body_en),
            ]);
        }
    }
};

Template with Rich Text Editor

<div>
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <!-- Arabic Fields -->
        <div class="space-y-4">
            <flux:heading size="sm">{{ __('admin.arabic_content') }}</flux:heading>

            <flux:field>
                <flux:label>{{ __('admin.title') }} (عربي) *</flux:label>
                <flux:input wire:model="title_ar" dir="rtl" />
                <flux:error name="title_ar" />
            </flux:field>

            <flux:field>
                <flux:label>{{ __('admin.body') }} (عربي) *</flux:label>
                <div wire:ignore>
                    <trix-editor
                        x-data
                        x-on:trix-change="$wire.set('body_ar', $event.target.value)"
                        dir="rtl"
                    >{!! $body_ar !!}</trix-editor>
                </div>
                <flux:error name="body_ar" />
            </flux:field>
        </div>

        <!-- English Fields -->
        <div class="space-y-4">
            <flux:heading size="sm">{{ __('admin.english_content') }}</flux:heading>

            <flux:field>
                <flux:label>{{ __('admin.title') }} (English) *</flux:label>
                <flux:input wire:model="title_en" />
                <flux:error name="title_en" />
            </flux:field>

            <flux:field>
                <flux:label>{{ __('admin.body') }} (English) *</flux:label>
                <div wire:ignore>
                    <trix-editor
                        x-data
                        x-on:trix-change="$wire.set('body_en', $event.target.value)"
                    >{!! $body_en !!}</trix-editor>
                </div>
                <flux:error name="body_en" />
            </flux:field>
        </div>
    </div>

    <div class="flex gap-3 mt-6">
        <flux:button wire:click="saveDraft">
            {{ __('admin.save_draft') }}
        </flux:button>
        <flux:button variant="primary" wire:click="publish">
            {{ __('admin.publish') }}
        </flux:button>
    </div>
</div>

Definition of Done

  • Can create post with bilingual content
  • Rich text editor works
  • Can save as draft
  • Can publish directly
  • Can edit existing posts
  • Auto-save works for drafts
  • HTML properly sanitized
  • Audit log created
  • Tests pass
  • Code formatted with Pint

Dependencies

  • Epic 1: Database schema, admin authentication

Estimation

Complexity: Medium Estimated Effort: 4-5 hours