diff --git a/AI_API.md b/AI_API.md index 53ae4df..716cc17 100644 --- a/AI_API.md +++ b/AI_API.md @@ -36,8 +36,11 @@ SocialAccount (login for a social network) Post (one piece of content, belongs to a Site, optionally a SocialAccount) └─ type: same vocabulary as SocialAccount.network, plus "blog" - └─ status: draft -> scheduled -> published | failed + └─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit) + └─ status: draft -> scheduled -> publishing -> published | failed └─ published_url: set once it's actually live somewhere + └─ publish_error: set to the failure reason when status=failed (or when a + publish attempt is rejected back to draft — see below) ``` The typical agent loop is: @@ -45,7 +48,14 @@ The typical agent loop is: 1. Read Sites/Social Accounts to know what you're working with (`GET /api/sites`, `GET /api/accounts`). 2. Create a Post as a draft (`POST /api/posts`), or find one that's already `due` (`GET /api/posts?due=1`). 3. Publish it externally, by whatever mechanism fits its `type`/site (see "How to actually publish" below). -4. Report the outcome back with `PATCH /api/posts/{id}` — set `status` to `published` or `failed`, and `published_url` if you have one, **unless** you used the `posts:publish` command, which already does this step for you. +4. Report the outcome back with `PATCH /api/posts/{id}` — set `status` to `published` or `failed`, and `published_url` if you have one, **unless** you used the `posts:publish` command or the app's own Publish button/job, which already does this step for you. + +`publishing` is a transient status set automatically while a background publish +job (triggered by the app's own "Publish" button in the UI) is running — you +won't normally set it yourself. If the underlying publisher rejects the post +before attempting anything (missing title, no target, credentials not set), +the job puts it back to `draft` rather than `failed`, since nothing was +actually attempted; check `publish_error` for why. ## Sites @@ -117,7 +127,7 @@ Full CRUD — this is the resource the agent actively drives. `GET /api/posts` Query params (all optional, combine freely): -- `?status=` — exact match: `draft` | `scheduled` | `published` | `failed` +- `?status=` — exact match: `draft` | `scheduled` | `publishing` | `published` | `failed` - `?site_id=` — exact match - `?social_account_id=` — exact match - `?due=1` — the important one for automation: returns posts where @@ -134,6 +144,7 @@ Body: "type": "blog", "title": "Optional title", "body": "Required content", + "target": null, "status": "draft", "scheduled_at": null } @@ -141,8 +152,9 @@ Body: Validation: `site_id` required, must exist. `social_account_id` nullable, must exist if given. `type` required, one of `twitter`, `instagram`, `facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`. -`body` required. `title` nullable. `status` defaults to `draft` if omitted. -`scheduled_at` nullable datetime. +`body` required. `title` nullable. `target` nullable — a network-specific +destination, e.g. the subreddit name for `type=reddit`. `status` defaults to +`draft` if omitted. `scheduled_at` nullable datetime. Note: `type` and the linked Social Account's `network` are **not** cross-validated — you can link a `type=blog` post to a Twitter account, or @@ -151,7 +163,7 @@ it's what later determines *how* the post gets published (see below). `PATCH /api/posts/{id}` — partial update. This is how you **report a publish result** back for anything you didn't publish via the -`posts:publish` command: +`posts:publish` command or the app's own Publish button: ```json { "status": "published", "published_url": "https://example.com/2026/07/my-post" } ``` @@ -164,9 +176,28 @@ or on failure: ## How to actually publish a post -This app is a data store and scheduler — **it does not publish content by -itself except via one specific command below.** Publishing mechanism -depends on the post's target: +Publishing happens through a shared `Publisher` abstraction +(`app/Support/Publishing/`) — one implementation per network family +(`WordPressPublisher`, `RedditPublisher`, more to come), resolved by `Post.type` +via `PublisherManager`. There are three ways to trigger it, depending on +who/what is driving: + +- **From the UI**: the Posts page has a "Publish" button (for any post whose + type currently has a publisher). Clicking it sets `status=publishing` and + dispatches `PublishPostJob` on the queue — a worker must be running + (`php artisan queue:work`) for anything to actually happen. The UI polls + and updates itself once the job finishes. +- **From the CLI**, for WordPress specifically: `php artisan posts:publish + {post_id}` (unchanged, see below) — synchronous, no queue needed. +- **Manually**, for anything without a wired-in publisher yet: do it + yourself and `PATCH` the result back (see below). + +Note the asymmetry: WordPress is synchronous (a single HTTP call, fast +enough to run inline), Reddit is not (it's real browser automation via +Playwright, tens of seconds) — which is exactly why the UI path is +queue-backed rather than a direct synchronous call. + +Publishing mechanism depends on the post's target: ### WordPress sites (`type=blog`, site has `cms_type=wordpress`) @@ -244,6 +275,7 @@ setting `status` and, if applicable, `published_url`.** | Task | How | |---|---| | Find work to publish | `GET /api/posts?due=1` | +| Publish from the UI | click "Publish" on the Posts page (queues `PublishPostJob`; needs a queue worker running) | | Publish to a WordPress site | `php artisan posts:publish {post_id}` (handles status/published_url itself) | | Publish to Reddit | `sidecars/reddit-post/run.sh ...`, then `PATCH /api/posts/{id}` yourself | | Publish anywhere else | do it yourself, then `PATCH /api/posts/{id}` yourself | diff --git a/README.md b/README.md index f3e2612..4e8b022 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,23 @@ Built with Laravel 13, Livewire, and Tailwind CSS. - **Social Accounts** — logins for social networks (Twitter, Instagram, Facebook, Reddit, TikTok, LinkedIn, YouTube, etc.). - **Posts** — the actual content, each tied to a Site and optionally a - Social Account, moving through `draft -> scheduled -> published/failed`. + Social Account, moving through + `draft -> scheduled -> publishing -> published/failed`. - A **JSON API** (`/api/sites`, `/api/accounts`, `/api/posts`) plus thin Python CLI wrappers in `python_scripts/`, so an agent can poll for due posts (`?due=1`) and drive the publish lifecycle without hand-rolling HTTP requests. -- Built-in publishing paths: - - **WordPress**: `php artisan posts:publish {post_id}` posts directly to - a site's WordPress REST API and updates the post's status. - - **Reddit**: a standalone Playwright sidecar at - `sidecars/reddit-post/` logs in and creates a post. - - Anything else: publish however fits, then report back via the API. +- A **Publish button** in the UI (Posts page) for any post whose network has + a publisher wired up — queues a background job so slow, flaky publishers + (e.g. Reddit's browser automation) don't block the page. +- Built-in publishers (`app/Support/Publishing/`), one per network, resolved + by `Post.type`: + - **WordPress**: posts directly to a site's WordPress REST API. Also + available synchronously from the CLI: `php artisan posts:publish {post_id}`. + - **Reddit**: drives a standalone Playwright sidecar at + `sidecars/reddit-post/` that logs in and creates a post. + - Anything else: no publisher yet — publish however fits, then report + back via the API. See `AI_API.md` for the full API reference and publishing contract, and `python_scripts/README.md` for the CLI wrapper usage. @@ -80,6 +86,14 @@ This starts the PHP dev server, queue listener, log viewer (Pail), and Vite dev server together. The app is served at `http://localhost:8000` by default. +Outside of `composer run dev` (e.g. a plain production deploy), the Publish +button needs its own queue worker running, or queued publishes will just sit +in the `jobs` table: + +```bash +php artisan queue:work +``` + ### Reddit sidecar (optional) If you plan to publish to Reddit, set up the Playwright sidecar separately: diff --git a/app/Console/Commands/PublishPost.php b/app/Console/Commands/PublishPost.php index d0b3305..84da121 100644 --- a/app/Console/Commands/PublishPost.php +++ b/app/Console/Commands/PublishPost.php @@ -3,9 +3,8 @@ namespace App\Console\Commands; use App\Models\Post; +use App\Support\Publishing\WordPressPublisher; use Illuminate\Console\Command; -use Illuminate\Http\Client\ConnectionException; -use Illuminate\Support\Facades\Http; class PublishPost extends Command { @@ -13,7 +12,7 @@ class PublishPost extends Command protected $description = 'Publish an existing blog post to its site\'s WordPress REST API'; - public function handle(): int + public function handle(WordPressPublisher $publisher): int { $post = Post::with('site')->find($this->argument('post')); @@ -25,48 +24,20 @@ class PublishPost extends Command return $this->failWith("Post {$post->id} has type '{$post->type}', expected 'blog' to publish to a CMS."); } - if (! $post->title) { - return $this->failWith("Post {$post->id} has no title, required to publish to WordPress."); + $result = $publisher->publish($post); + + if ($result->success) { + $post->update(['status' => 'published', 'published_url' => $result->url, 'publish_error' => null]); + $this->line(json_encode(['success' => true, 'url' => $result->url])); + + return self::SUCCESS; } - $site = $post->site; - - if ($site->cms_type !== 'wordpress') { - return $this->failWith("Site '{$site->name}' is not configured as WordPress (cms_type={$site->cms_type})."); + if ($result->attempted) { + $post->update(['status' => 'failed', 'publish_error' => $result->error]); } - if (! $site->wp_username || ! $site->wp_app_password) { - return $this->failWith("Site '{$site->name}' is missing WordPress credentials."); - } - - try { - $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password) - ->post(rtrim($site->url, '/') . '/wp-json/wp/v2/posts', [ - 'title' => $post->title, - 'content' => $post->body, - 'status' => 'publish', - ]); - } catch (ConnectionException $e) { - return $this->publishFailed($post, "could not connect to site: {$e->getMessage()}"); - } - - if ($response->failed()) { - $message = $response->json('message') ?? 'unexpected response from WordPress'; - - return $this->publishFailed($post, $message); - } - - $link = $response->json('link'); - - if (! $link) { - return $this->publishFailed($post, 'unexpected response from WordPress: missing post link'); - } - - $post->update(['status' => 'published', 'published_url' => $link]); - - $this->line(json_encode(['success' => true, 'url' => $link])); - - return self::SUCCESS; + return $this->failWith($result->error); } private function failWith(string $message): int @@ -75,11 +46,4 @@ class PublishPost extends Command return self::FAILURE; } - - private function publishFailed(Post $post, string $message): int - { - $post->update(['status' => 'failed']); - - return $this->failWith($message); - } } diff --git a/app/Http/Requests/StorePostRequest.php b/app/Http/Requests/StorePostRequest.php index 0ee9d9f..9984127 100644 --- a/app/Http/Requests/StorePostRequest.php +++ b/app/Http/Requests/StorePostRequest.php @@ -20,6 +20,7 @@ class StorePostRequest extends FormRequest 'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)], 'title' => ['nullable', 'string', 'max:255'], 'body' => ['required', 'string'], + 'target' => ['nullable', 'string', 'max:255'], 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], 'scheduled_at' => ['nullable', 'date'], ]; diff --git a/app/Http/Requests/UpdatePostRequest.php b/app/Http/Requests/UpdatePostRequest.php index 76c24bf..855c356 100644 --- a/app/Http/Requests/UpdatePostRequest.php +++ b/app/Http/Requests/UpdatePostRequest.php @@ -20,8 +20,10 @@ class UpdatePostRequest extends FormRequest 'type' => ['sometimes', 'required', 'string', 'in:' . implode(',', Post::TYPES)], 'title' => ['nullable', 'string', 'max:255'], 'body' => ['sometimes', 'required', 'string'], + 'target' => ['nullable', 'string', 'max:255'], 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], 'scheduled_at' => ['nullable', 'date'], + 'published_url' => ['nullable', 'string', 'max:255'], ]; } } diff --git a/app/Http/Resources/PostResource.php b/app/Http/Resources/PostResource.php index 8981fa0..6d1f12f 100644 --- a/app/Http/Resources/PostResource.php +++ b/app/Http/Resources/PostResource.php @@ -16,9 +16,11 @@ class PostResource extends JsonResource 'type' => $this->type, 'title' => $this->title, 'body' => $this->body, + 'target' => $this->target, 'status' => $this->status, 'scheduled_at' => $this->scheduled_at, 'published_url' => $this->published_url, + 'publish_error' => $this->publish_error, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Jobs/PublishPostJob.php b/app/Jobs/PublishPostJob.php new file mode 100644 index 0000000..0172eef --- /dev/null +++ b/app/Jobs/PublishPostJob.php @@ -0,0 +1,53 @@ +find($this->postId); + + if (! $post) { + return; + } + + $publisher = $publishers->resolve($post); + + if (! $publisher) { + $post->update(['status' => 'draft', 'publish_error' => "No publisher available for post type '{$post->type}'."]); + + return; + } + + $result = $publisher->publish($post); + + if ($result->success) { + $post->update(['status' => 'published', 'published_url' => $result->url, 'publish_error' => null]); + + return; + } + + $post->update([ + 'status' => $result->attempted ? 'failed' : 'draft', + 'publish_error' => $result->error, + ]); + + Log::error('post publish failed', ['post_id' => $post->id, 'error' => $result->error]); + } +} diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php index 24fe14d..bb20e0e 100644 --- a/app/Livewire/Auth/Login.php +++ b/app/Livewire/Auth/Login.php @@ -28,7 +28,7 @@ class Login extends Component return; } - request()->session()->regenerate(); + session()->regenerate(); $this->redirect(route('sites.index'), navigate: true); } diff --git a/app/Livewire/Posts/Index.php b/app/Livewire/Posts/Index.php index bf6c9ee..74a47c4 100644 --- a/app/Livewire/Posts/Index.php +++ b/app/Livewire/Posts/Index.php @@ -2,9 +2,11 @@ namespace App\Livewire\Posts; +use App\Jobs\PublishPostJob; use App\Models\Post; use App\Models\Site; use App\Models\SocialAccount; +use App\Support\Publishing\PublisherManager; use Livewire\Attributes\Layout; use Livewire\Component; @@ -25,21 +27,45 @@ class Index extends Component public string $body = ''; + public string $target = ''; + public string $status = 'draft'; public string $scheduledAt = ''; + public ?string $publishNotice = null; + public function render() { + $posts = Post::with(['site', 'socialAccount'])->orderByDesc('created_at')->get(); + return view('livewire.posts.index', [ - 'posts' => Post::with(['site', 'socialAccount'])->orderByDesc('created_at')->get(), + 'posts' => $posts, 'sites' => Site::orderBy('name')->get(), 'accounts' => SocialAccount::orderBy('username')->get(), 'types' => Post::TYPES, 'statuses' => Post::STATUSES, + 'hasPublishing' => $posts->contains('status', 'publishing'), ]); } + public function publish(int $id): void + { + $this->publishNotice = null; + + $post = Post::with('site')->findOrFail($id); + + if (! app(PublisherManager::class)->resolve($post)) { + $this->publishNotice = "No publisher available yet for post type '{$post->type}'."; + + return; + } + + $post->update(['status' => 'publishing', 'publish_error' => null]); + + PublishPostJob::dispatch($post->id); + } + public function create(): void { $this->resetForm(); @@ -55,6 +81,7 @@ class Index extends Component $this->type = $post->type; $this->title = (string) $post->title; $this->body = $post->body; + $this->target = (string) $post->target; $this->status = $post->status; $this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? ''; $this->showForm = true; @@ -68,6 +95,7 @@ class Index extends Component 'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)], 'title' => ['nullable', 'string', 'max:255'], 'body' => ['required', 'string'], + 'target' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)], 'scheduledAt' => ['nullable', 'date'], ]); @@ -78,6 +106,7 @@ class Index extends Component 'type' => $validated['type'], 'title' => $validated['title'] ?: null, 'body' => $validated['body'], + 'target' => $validated['target'] ?: null, 'status' => $validated['status'], 'scheduled_at' => $validated['scheduledAt'] ?: null, ]); @@ -99,6 +128,7 @@ class Index extends Component $this->type = ''; $this->title = ''; $this->body = ''; + $this->target = ''; $this->status = 'draft'; $this->scheduledAt = ''; $this->resetValidation(); diff --git a/app/Models/Post.php b/app/Models/Post.php index c1802a1..e47b53d 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -15,7 +15,7 @@ class Post extends Model 'tiktok', 'linkedin', 'youtube', 'blog', 'other', ]; - public const STATUSES = ['draft', 'scheduled', 'published', 'failed']; + public const STATUSES = ['draft', 'scheduled', 'publishing', 'published', 'failed']; protected $fillable = [ 'site_id', @@ -23,9 +23,11 @@ class Post extends Model 'type', 'title', 'body', + 'target', 'status', 'scheduled_at', 'published_url', + 'publish_error', ]; protected $casts = [ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..2cd8c6a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Support\Publishing\PublisherManager; +use App\Support\Publishing\RedditPublisher; +use App\Support\Publishing\WordPressPublisher; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +14,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(PublisherManager::class, fn ($app) => new PublisherManager([ + $app->make(WordPressPublisher::class), + $app->make(RedditPublisher::class), + ])); } /** diff --git a/app/Support/Publishing/PublishResult.php b/app/Support/Publishing/PublishResult.php new file mode 100644 index 0000000..bdd79c4 --- /dev/null +++ b/app/Support/Publishing/PublishResult.php @@ -0,0 +1,36 @@ + $publishers + */ + public function __construct(private readonly array $publishers) {} + + public function resolve(Post $post): ?Publisher + { + foreach ($this->publishers as $publisher) { + if ($publisher->supports($post)) { + return $publisher; + } + } + + return null; + } +} diff --git a/app/Support/Publishing/RedditPublisher.php b/app/Support/Publishing/RedditPublisher.php new file mode 100644 index 0000000..a1bf82a --- /dev/null +++ b/app/Support/Publishing/RedditPublisher.php @@ -0,0 +1,59 @@ +type === 'reddit'; + } + + public function publish(Post $post): PublishResult + { + if (! $post->title) { + return PublishResult::rejected("Post {$post->id} has no title, required for a Reddit post."); + } + + if (! $post->target) { + return PublishResult::rejected("Post {$post->id} has no target subreddit set."); + } + + $account = $post->socialAccount; + + if (! $account || $account->network !== 'reddit') { + return PublishResult::rejected("Post {$post->id} has no linked Reddit social account."); + } + + $result = Process::timeout(120)->run([ + base_path('sidecars/reddit-post/run.sh'), + "--username={$account->username}", + "--password={$account->password}", + "--subreddit={$post->target}", + "--title={$post->title}", + "--body={$post->body}", + ]); + + $data = json_decode(trim($result->output()), true); + + if (! is_array($data) || ! array_key_exists('success', $data)) { + Log::error('reddit sidecar returned unparseable output', [ + 'post_id' => $post->id, + 'output' => $result->output(), + 'stderr' => $result->errorOutput(), + ]); + + return PublishResult::failed('Reddit sidecar returned unparseable output — check logs.'); + } + + if (! $data['success']) { + return PublishResult::failed($data['error'] ?? 'unknown Reddit sidecar failure'); + } + + return PublishResult::success($data['url']); + } +} diff --git a/app/Support/Publishing/WordPressPublisher.php b/app/Support/Publishing/WordPressPublisher.php new file mode 100644 index 0000000..9114604 --- /dev/null +++ b/app/Support/Publishing/WordPressPublisher.php @@ -0,0 +1,55 @@ +type === 'blog'; + } + + public function publish(Post $post): PublishResult + { + if (! $post->title) { + return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress."); + } + + $site = $post->site; + + if ($site->cms_type !== 'wordpress') { + return PublishResult::rejected("Site '{$site->name}' is not configured as WordPress (cms_type={$site->cms_type})."); + } + + if (! $site->wp_username || ! $site->wp_app_password) { + return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials."); + } + + try { + $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password) + ->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [ + 'title' => $post->title, + 'content' => $post->body, + 'status' => 'publish', + ]); + } catch (ConnectionException $e) { + return PublishResult::failed("could not connect to site: {$e->getMessage()}"); + } + + if ($response->failed()) { + return PublishResult::failed($response->json('message') ?? 'unexpected response from WordPress'); + } + + $link = $response->json('link'); + + if (! $link) { + return PublishResult::failed('unexpected response from WordPress: missing post link'); + } + + return PublishResult::success($link); + } +} diff --git a/database/migrations/2026_07_21_133845_add_publishing_fields_to_posts_table.php b/database/migrations/2026_07_21_133845_add_publishing_fields_to_posts_table.php new file mode 100644 index 0000000..40e4bb4 --- /dev/null +++ b/database/migrations/2026_07_21_133845_add_publishing_fields_to_posts_table.php @@ -0,0 +1,35 @@ +string('target')->nullable()->after('body'); + $table->text('publish_error')->nullable()->after('published_url'); + }); + + DB::statement("ALTER TABLE posts MODIFY status ENUM('draft', 'scheduled', 'publishing', 'published', 'failed') NOT NULL DEFAULT 'draft'"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement("UPDATE posts SET status = 'failed' WHERE status = 'publishing'"); + DB::statement("ALTER TABLE posts MODIFY status ENUM('draft', 'scheduled', 'published', 'failed') NOT NULL DEFAULT 'draft'"); + + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn(['target', 'publish_error']); + }); + } +}; diff --git a/python_scripts/posts.py b/python_scripts/posts.py index 23cd8dc..954b052 100755 --- a/python_scripts/posts.py +++ b/python_scripts/posts.py @@ -5,10 +5,10 @@ Usage: python3 posts.py list [--status STATUS] [--site-id ID] [--social-account-id ID] [--due] python3 posts.py get POST_ID python3 posts.py create --site-id ID --type TYPE --body BODY - [--social-account-id ID] [--title TITLE] + [--social-account-id ID] [--title TITLE] [--target TARGET] [--status STATUS] [--scheduled-at DATETIME] python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID] - [--type TYPE] [--title TITLE] [--body BODY] + [--type TYPE] [--title TITLE] [--body BODY] [--target TARGET] [--status STATUS] [--scheduled-at DATETIME] [--published-url URL] python3 posts.py delete POST_ID @@ -22,7 +22,7 @@ import argparse import _client TYPES = ["twitter", "instagram", "facebook", "reddit", "tiktok", "linkedin", "youtube", "blog", "other"] -STATUSES = ["draft", "scheduled", "published", "failed"] +STATUSES = ["draft", "scheduled", "publishing", "published", "failed"] def main() -> None: @@ -46,6 +46,7 @@ def main() -> None: create_parser.add_argument("--body", required=True) create_parser.add_argument("--social-account-id", type=int) create_parser.add_argument("--title") + create_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts") create_parser.add_argument("--status", choices=STATUSES) create_parser.add_argument("--scheduled-at", help="ISO 8601 datetime, e.g. 2026-07-20T09:00:00") @@ -56,6 +57,7 @@ def main() -> None: update_parser.add_argument("--type", choices=TYPES) update_parser.add_argument("--title") update_parser.add_argument("--body") + update_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts") update_parser.add_argument("--status", choices=STATUSES) update_parser.add_argument("--scheduled-at", help="ISO 8601 datetime") update_parser.add_argument("--published-url") @@ -85,6 +87,7 @@ def main() -> None: "type": args.type, "title": args.title, "body": args.body, + "target": args.target, "status": args.status, "scheduled_at": args.scheduled_at, } @@ -98,6 +101,7 @@ def main() -> None: "type": args.type, "title": args.title, "body": args.body, + "target": args.target, "status": args.status, "scheduled_at": args.scheduled_at, "published_url": args.published_url, diff --git a/resources/views/livewire/posts/index.blade.php b/resources/views/livewire/posts/index.blade.php index a631068..2af717a 100644 --- a/resources/views/livewire/posts/index.blade.php +++ b/resources/views/livewire/posts/index.blade.php @@ -1,9 +1,13 @@ -
| {{ $post->site->name }} | {{ ucfirst($post->type) }} | {{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }} | -{{ ucfirst($post->status) }} | +
+ @if ($post->status === 'published' && $post->published_url)
+ Published
+ @else
+ {{ ucfirst($post->status) }}
+ @endif
+ @if ($post->status === 'failed' && $post->publish_error)
+ {{ $post->publish_error }}
+ @endif
+ |
{{ $post->scheduled_at?->format('Y-m-d H:i') ?? '—' }} | {{ $post->socialAccount?->username ?? '—' }} | + @if (in_array($post->type, ['blog', 'reddit']) && in_array($post->status, ['draft', 'scheduled', 'failed'])) + + @endif | @@ -84,6 +100,12 @@ @error('body') {{ $message }} @enderror +