269 lines
7.0 KiB
Markdown
269 lines
7.0 KiB
Markdown
# 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
|
|
```php
|
|
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
|
|
```php
|
|
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
|
|
<?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
|
|
```blade
|
|
<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
|