Add Post Templates feature
- Create templates table with name, body, type, title, target, placeholders - Add Template model with applyToPost method for placeholder replacement - Add Livewire Templates/Index component for CRUD operations - Add templates index view with form and table - Add /templates route - Update Posts/Index to support template selection when creating posts - Templates can be applied to pre-fill post form fields Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Jobs\PublishPostJob;
|
||||
use App\Models\Post;
|
||||
use App\Models\Site;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Template;
|
||||
use App\Support\Publishing\PublisherManager;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
@@ -17,6 +18,8 @@ class Index extends Component
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public ?int $templateId = null;
|
||||
|
||||
public string $siteId = '';
|
||||
|
||||
public string $socialAccountId = '';
|
||||
@@ -47,6 +50,7 @@ class Index extends Component
|
||||
'accounts' => SocialAccount::orderBy('username')->get(),
|
||||
'types' => Post::TYPES,
|
||||
'statuses' => Post::STATUSES,
|
||||
'templates' => Template::orderBy('name')->get(),
|
||||
'hasPublishing' => $posts->contains('status', 'publishing'),
|
||||
]);
|
||||
}
|
||||
@@ -74,6 +78,37 @@ class Index extends Component
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function createFromTemplate(int $templateId): void
|
||||
{
|
||||
$template = Template::findOrFail($templateId);
|
||||
|
||||
$this->resetForm();
|
||||
$this->templateId = $template->id;
|
||||
|
||||
// Pre-fill form from template
|
||||
if ($template->type) {
|
||||
$this->type = $template->type;
|
||||
}
|
||||
if ($template->title) {
|
||||
$this->title = $template->title;
|
||||
}
|
||||
if ($template->body) {
|
||||
$this->body = $template->body;
|
||||
}
|
||||
if ($template->target) {
|
||||
$this->target = $template->target;
|
||||
}
|
||||
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function applyTemplate(): void
|
||||
{
|
||||
if ($this->templateId) {
|
||||
$this->createFromTemplate($this->templateId);
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(int $id): void
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
@@ -128,6 +163,7 @@ class Index extends Component
|
||||
public function resetForm(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->templateId = null;
|
||||
$this->siteId = '';
|
||||
$this->socialAccountId = '';
|
||||
$this->type = '';
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Templates;
|
||||
|
||||
use App\Models\Template;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('components.layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
public bool $showForm = false;
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public ?string $type = null;
|
||||
|
||||
public ?string $title = null;
|
||||
|
||||
public ?string $target = null;
|
||||
|
||||
public string $placeholders = '';
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.templates.index', [
|
||||
'templates' => Template::orderBy('name')->get(),
|
||||
'types' => Template::TYPES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->resetForm();
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function edit(int $id): void
|
||||
{
|
||||
$template = Template::findOrFail($id);
|
||||
$this->editingId = $template->id;
|
||||
$this->name = $template->name;
|
||||
$this->body = $template->body;
|
||||
$this->type = $template->type;
|
||||
$this->title = $template->title;
|
||||
$this->target = $template->target;
|
||||
$this->placeholders = $template->placeholders ? implode(', ', $template->placeholders) : '';
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'body' => ['required', 'string'],
|
||||
'type' => ['nullable', 'string', 'in:' . implode(',', Template::TYPES)],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'target' => ['nullable', 'string', 'max:255'],
|
||||
'placeholders' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
// Parse placeholders from comma-separated string
|
||||
$placeholderArray = null;
|
||||
if ($validated['placeholders']) {
|
||||
$placeholderArray = array_map('trim', explode(',', $validated['placeholders']));
|
||||
$placeholderArray = array_filter($placeholderArray);
|
||||
}
|
||||
|
||||
Template::updateOrCreate(['id' => $this->editingId], [
|
||||
'name' => $validated['name'],
|
||||
'body' => $validated['body'],
|
||||
'type' => $validated['type'],
|
||||
'title' => $validated['title'],
|
||||
'target' => $validated['target'],
|
||||
'placeholders' => $placeholderArray,
|
||||
]);
|
||||
|
||||
$this->resetForm();
|
||||
$this->showForm = false;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
Template::findOrFail($id)->delete();
|
||||
}
|
||||
|
||||
public function resetForm(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->name = '';
|
||||
$this->body = '';
|
||||
$this->type = null;
|
||||
$this->title = null;
|
||||
$this->target = null;
|
||||
$this->placeholders = '';
|
||||
$this->resetValidation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Template extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPES = [
|
||||
'twitter', 'instagram', 'facebook', 'reddit',
|
||||
'tiktok', 'linkedin', 'youtube', 'blog', 'other',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'body',
|
||||
'type',
|
||||
'title',
|
||||
'target',
|
||||
'placeholders',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'placeholders' => 'array',
|
||||
];
|
||||
|
||||
public function applyToPost(array $data): array
|
||||
{
|
||||
$result = [
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'type' => $this->type,
|
||||
'target' => $this->target,
|
||||
];
|
||||
|
||||
// Replace placeholders in all text fields
|
||||
if ($this->placeholders && is_array($data)) {
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, $this->placeholders)) {
|
||||
foreach (['title', 'body', 'target'] as $field) {
|
||||
if ($result[$field]) {
|
||||
$result[$field] = str_replace("{{$key}}", $value, $result[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('body')->nullable();
|
||||
$table->string('type')->nullable(); // blog, twitter, reddit, etc.
|
||||
$table->string('title')->nullable();
|
||||
$table->string('target')->nullable(); // e.g., subreddit for reddit
|
||||
$table->json('placeholders')->nullable(); // JSON array of placeholder keys
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('templates');
|
||||
}
|
||||
};
|
||||
@@ -63,6 +63,21 @@
|
||||
<div class="bg-white p-6 rounded w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Post' : 'Add Post' }}</h2>
|
||||
|
||||
@if (count($templates) > 0 && !$editingId)
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Template (optional)</label>
|
||||
<select wire:model.live="templateId" class="w-full border rounded p-2">
|
||||
<option value="">No template</option>
|
||||
@foreach ($templates as $template)
|
||||
<option value="{{ $template->id }}">{{ $template->name }} ({{ $template->type ? ucfirst($template->type) : 'any' }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if ($templateId)
|
||||
<button wire:click="applyTemplate" class="mt-2 text-sm text-blue-600">Apply Template</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Site</label>
|
||||
<select wire:model="siteId" class="w-full border rounded p-2">
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-xl font-semibold">Templates</h1>
|
||||
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Template</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 text-blue-800 rounded text-sm">
|
||||
<strong>Tip:</strong> Use <code>{{placeholder}}</code> syntax in your templates.
|
||||
For example: <code>Welcome {{name}} to {{site}}</code>.
|
||||
Define placeholders as comma-separated values in the form below.
|
||||
</div>
|
||||
|
||||
<table class="w-full bg-white border border-gray-200 rounded">
|
||||
<thead>
|
||||
<tr class="text-left border-b border-gray-200">
|
||||
<th class="p-3">Name</th>
|
||||
<th class="p-3">Type</th>
|
||||
<th class="p-3">Preview</th>
|
||||
<th class="p-3">Placeholders</th>
|
||||
<th class="p-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($templates as $template)
|
||||
<tr wire:key="template-{{ $template->id }}" class="border-b border-gray-100">
|
||||
<td class="p-3 font-medium">{{ $template->name }}</td>
|
||||
<td class="p-3">{{ $template->type ? ucfirst($template->type) : '-' }}</td>
|
||||
<td class="p-3 text-sm text-gray-600">
|
||||
{{ Str::limit($template->body ?? $template->title ?? '', 50) }}
|
||||
</td>
|
||||
<td class="p-3">
|
||||
@if ($template->placeholders)
|
||||
<span class="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{{ implode(', ', $template->placeholders) }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="p-3 text-right space-x-2">
|
||||
<button wire:click="edit({{ $template->id }})" class="text-blue-600">Edit</button>
|
||||
<button wire:click="delete({{ $template->id }})" wire:confirm="Delete this template?" class="text-red-600">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if ($showForm)
|
||||
<div class="fixed inset-0 bg-black/30 flex items-center justify-center">
|
||||
<div class="bg-white p-6 rounded w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Template' : 'Add Template' }}</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Name <span class="text-red-500">*</span></label>
|
||||
<input type="text" wire:model="name" class="w-full border rounded p-2" placeholder="e.g., Blog Post Intro">
|
||||
@error('name') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Type (optional)</label>
|
||||
<select wire:model="type" class="w-full border rounded p-2">
|
||||
<option value="">Any type</option>
|
||||
@foreach ($types as $t)
|
||||
<option value="{{ $t }}">{{ ucfirst($t) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('type') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Title (optional)</label>
|
||||
<input type="text" wire:model="title" class="w-full border rounded p-2" placeholder="Post title template">
|
||||
@error('title') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Body <span class="text-red-500">*</span></label>
|
||||
<textarea wire:model="body" rows="6" class="w-full border rounded p-2 font-mono text-sm" placeholder="Your post content template... Use {{placeholder}} syntax"></textarea>
|
||||
@error('body') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Target (optional)</label>
|
||||
<input type="text" wire:model="target" class="w-full border rounded p-2" placeholder="e.g., programming, my-blog-category">
|
||||
<p class="text-xs text-gray-500 mt-1">For Reddit: subreddit name. For WordPress: category or tag.</p>
|
||||
@error('target') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Placeholders (comma-separated, optional)</label>
|
||||
<input type="text" wire:model="placeholders" class="w-full border rounded p-2" placeholder="e.g., name, site, date">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Define placeholders like <code>{{name}}</code>, <code>{{site}}</code> that can be replaced when creating a post.
|
||||
</p>
|
||||
@error('placeholders') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||||
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@ use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Posts\Index as PostsIndex;
|
||||
use App\Livewire\Sites\Index as SitesIndex;
|
||||
use App\Livewire\SocialAccounts\Index as SocialAccountsIndex;
|
||||
use App\Livewire\Templates\Index as TemplatesIndex;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -25,4 +26,5 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/sites', SitesIndex::class)->name('sites.index');
|
||||
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');
|
||||
Route::get('/posts', PostsIndex::class)->name('posts.index');
|
||||
Route::get('/templates', TemplatesIndex::class)->name('templates.index');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user