7.0 KiB
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