feat: email sub/unsub/queue end-to-end rough draft
This commit is contained in:
parent
53740b8ad3
commit
0ad6f8d833
12
.env.example
12
.env.example
|
|
@ -1,3 +1,7 @@
|
||||||
|
PUBLISHING_NAME="Word Salad"
|
||||||
|
SUBHEAD="A blog with an as yet unclear direction."
|
||||||
|
CONTACT_EMAIL="test@example.org"
|
||||||
|
|
||||||
APP_NAME=Laravel
|
APP_NAME=Laravel
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
|
|
@ -13,7 +17,7 @@ DB_CONNECTION=sqlite
|
||||||
BROADCAST_DRIVER=log
|
BROADCAST_DRIVER=log
|
||||||
CACHE_DRIVER=file
|
CACHE_DRIVER=file
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=database
|
||||||
SESSION_DRIVER=file
|
SESSION_DRIVER=file
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
|
|
@ -24,8 +28,4 @@ MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_ENCRYPTION=null
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${PUBLISHING_NAME}"
|
||||||
|
|
||||||
PUBLISHING_NAME="Word Salad"
|
|
||||||
SUBHEAD="A blog with an as yet unclear direction."
|
|
||||||
CONTACT_EMAIL="test@example.org"
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Mail\NewArticlePublished;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Subscriber;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
class QueueNewArticleAlertForSubscribers extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'app:queue-new-article-alert-for-subscribers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Gets all the published articles that have yet to have subscribers notified and queues notification emails for all the subscribers in the list.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// TODO: Incredibly basic implementation here. Won't scale well past a few thousand subscribers
|
||||||
|
$articlesForNotification = Article::query()
|
||||||
|
->where('published_at', '<', now())
|
||||||
|
->whereNull('subscribers_notified_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
logger(count($articlesForNotification) . ' article(s) ready for notifying.');
|
||||||
|
|
||||||
|
$subscribersToNotify = Subscriber::all();
|
||||||
|
|
||||||
|
logger(count($subscribersToNotify) . ' subscribers(s) to notify.');
|
||||||
|
|
||||||
|
logger((count($articlesForNotification) * count($subscribersToNotify)) . ' emails being queued for send.');
|
||||||
|
|
||||||
|
foreach($articlesForNotification as $article) {
|
||||||
|
|
||||||
|
$article->update(['subscribers_notified_at' => now()]);
|
||||||
|
|
||||||
|
foreach($subscribersToNotify as $subscriber) {
|
||||||
|
Mail::to($subscriber)->queue(new NewArticlePublished($article, $subscriber));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$endTime = microtime(true);
|
||||||
|
|
||||||
|
logger('Command Succeeded in ' . ($endTime - $startTime) . ' second(s).');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ class Kernel extends ConsoleKernel
|
||||||
*/
|
*/
|
||||||
protected function schedule(Schedule $schedule): void
|
protected function schedule(Schedule $schedule): void
|
||||||
{
|
{
|
||||||
// $schedule->command('inspire')->hourly();
|
$schedule->command('app:queue-new-article-alert-for-subscribers')->everyFiveMinutes()->withoutOverlapping();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,19 @@ class ReaderController extends Controller
|
||||||
return redirect(route('subscribe-fail'));
|
return redirect(route('subscribe-fail'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function unsubscribe(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
// Validate email
|
||||||
|
$validated = $request->validate([
|
||||||
|
'email' => 'required|email|max:512',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if($validated['email']) {
|
||||||
|
Subscriber::where('email', $validated['email'])->delete();
|
||||||
|
return redirect(route('unsubscribe-success'));
|
||||||
|
} else {
|
||||||
|
return redirect(route('unsubscribe-fail'));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,9 @@ class WriterController extends Controller
|
||||||
|
|
||||||
public function dashboard(Request $request): View
|
public function dashboard(Request $request): View
|
||||||
{
|
{
|
||||||
return view('writer.dashboard');
|
$allArticles = Article::with(['author'])->orderBy('last_updated')->get();
|
||||||
|
|
||||||
|
return view('writer.dashboard', ['articles' => $allArticles]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function newArticle(Request $request): View
|
public function newArticle(Request $request): View
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Subscriber;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class NewArticlePublished extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new message instance.
|
||||||
|
*/
|
||||||
|
public function __construct(private Article $article, private ?Subscriber $subscriber)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the message envelope.
|
||||||
|
*/
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: $this->article->title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the message content definition.
|
||||||
|
*/
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
markdown: 'mail.articles.published',
|
||||||
|
with: [
|
||||||
|
'article' => $this->article,
|
||||||
|
'email' => $this?->subscriber?->email,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attachments for the message.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||||
|
*/
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ class Article extends Model
|
||||||
'header_image_url',
|
'header_image_url',
|
||||||
'markdown_body',
|
'markdown_body',
|
||||||
'processed_at',
|
'processed_at',
|
||||||
|
'subscribers_notified_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** ---------------------------------------
|
/** ---------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"mailpit": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"mailpit"
|
||||||
|
],
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi"
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ return new class extends Migration
|
||||||
// HTML body was generated (among other things)
|
// HTML body was generated (among other things)
|
||||||
$table->dateTime('processed_at')->nullable();
|
$table->dateTime('processed_at')->nullable();
|
||||||
|
|
||||||
|
// The dateTime when the emails were queued for sending for all subscribers
|
||||||
|
// after first publishing
|
||||||
|
$table->dateTime('subscribers_notified_at')->nullable();
|
||||||
|
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('jobs', function (Blueprint $table) {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->string('queue')->index();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->unsignedTinyInteger('attempts');
|
||||||
|
$table->unsignedInteger('reserved_at')->nullable();
|
||||||
|
$table->unsignedInteger('available_at');
|
||||||
|
$table->unsignedInteger('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
.is-invalid {
|
.is-invalid {
|
||||||
border: red 4px solid;
|
border: red 4px solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#article-list-table {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,10 @@ footer {
|
||||||
margin-bottom: 2.5em;
|
margin-bottom: 2.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#writer-login {
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* .bubble-panel {
|
/* .bubble-panel {
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
background: #813d9c;
|
background: #813d9c;
|
||||||
|
|
@ -186,34 +190,3 @@ footer {
|
||||||
33px -33px 65px #9c4abd;
|
33px -33px 65px #9c4abd;
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
|
|
||||||
/* Create a custom animation */
|
|
||||||
@keyframes move-out {
|
|
||||||
from {
|
|
||||||
transform: translateY(0%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes move-in {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Apply the custom animation to the old and new page states */
|
|
||||||
::view-transition-old(root) {
|
|
||||||
animation: 0.4s ease-in both move-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-new(root) {
|
|
||||||
animation: 0.4s ease-in both move-in;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
@extends('layouts.guest')
|
||||||
|
|
||||||
|
@section('title', 'Un-Subscription Failed')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section style="max-width: 1100px;">
|
||||||
|
<h1>Error Attempting to Unsubscribe</h1>
|
||||||
|
<aside>(yikes, I am embarrased)</aside>
|
||||||
|
<p>Something went wrong while removing your interest from my list. Please try again, or contact me at {{ config('blog.contact_email') }} if the problem persists.</p>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
@extends('layouts.guest')
|
||||||
|
|
||||||
|
@section('title', 'Subscription Removed')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section style="max-width: 1100px;">
|
||||||
|
<h1>Subscription Removed</h1>
|
||||||
|
<p>Please allow up to five minutes for this to take effect*. You will no longer receive emails from me. Thank you for your interest.</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<aside>
|
||||||
|
<i>
|
||||||
|
*Ever wonder why unsubscribe pages often say something like this?
|
||||||
|
Well, there are a number of common reasons, but in this case, it's
|
||||||
|
because emails take a little bit of time to send. So if I need to
|
||||||
|
notify 10,000 subscribers about a new article, and it takes a tenth
|
||||||
|
of a second to send each email, then from the time I start sending
|
||||||
|
to the first subscriber in the list to the last subscriber, several
|
||||||
|
minutes have passed. And it's very possible you clicked the
|
||||||
|
unsubscribe link somewhere in that time frame and an email has
|
||||||
|
already been queued up to be sent.
|
||||||
|
</i>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
@extends('layouts.guest')
|
||||||
|
|
||||||
|
@section('title', 'Unsubscribe')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section>
|
||||||
|
<h1>Unsubscribe from All Emails</h1>
|
||||||
|
<form action="{{ route('unsubscribe-save') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ $email }}" required />
|
||||||
|
<button type="submit">Unsubscribe</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
|
|
@ -26,11 +26,11 @@
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
<footer>
|
<footer>
|
||||||
@if(! in_array(Route::currentRouteName(), ['subscribe-success', 'subscribe-fail']))
|
@if($showSubscribeForm ?? false)
|
||||||
@include('shared.subscribe-form')
|
@include('shared.subscribe-form')
|
||||||
@endif
|
@endif
|
||||||
<div id="footer-copyright">
|
<div id="footer-copyright">
|
||||||
© {{ (new DateTime())->format('Y') }}
|
© {{ date('Y') }}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<x-mail::message>
|
||||||
|
# {{ $article->title }}
|
||||||
|
|
||||||
|
## {{ $article->subtitle }}
|
||||||
|
|
||||||
|
Author: {{ $article?->author?->display_name }}
|
||||||
|
|
||||||
|
<x-mail::button :url="route('article', ['article_slug' => $article->slug])">
|
||||||
|
Read in Browser
|
||||||
|
</x-mail::button>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
|
||||||
|
{!! $article->body !!}
|
||||||
|
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{{ config('blog.publishing_name') }}
|
||||||
|
|
||||||
|
<x-mail::button :url="route('unsubscribe', ['email' => $email])" color="error">
|
||||||
|
Unsubscribe
|
||||||
|
</x-mail::button>
|
||||||
|
|
||||||
|
</x-mail::message>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
@props([
|
||||||
|
'url',
|
||||||
|
'color' => 'primary',
|
||||||
|
'align' => 'center',
|
||||||
|
])
|
||||||
|
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="{{ $align }}">
|
||||||
|
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="{{ $align }}">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{{ $slot }}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell" align="center">
|
||||||
|
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
@props(['url'])
|
||||||
|
<tr>
|
||||||
|
<td class="header">
|
||||||
|
<a href="{{ $url }}" style="display: inline-block;">
|
||||||
|
@if (trim($slot) === 'Laravel')
|
||||||
|
<img src="https://laravel.com/img/notification-logo.png" class="logo" alt="Laravel Logo">
|
||||||
|
@else
|
||||||
|
{{ $slot }}
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<title>{{ config('blog.publishing_name') }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<style>
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.inner-body {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 500px) {
|
||||||
|
.button {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
{{ $header ?? '' }}
|
||||||
|
|
||||||
|
<!-- Email Body -->
|
||||||
|
<tr>
|
||||||
|
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
|
||||||
|
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<!-- Body content -->
|
||||||
|
<tr>
|
||||||
|
<td class="content-cell">
|
||||||
|
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||||
|
|
||||||
|
{{ $subcopy ?? '' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{{ $footer ?? '' }}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<x-mail::layout>
|
||||||
|
{{-- Header --}}
|
||||||
|
<x-slot:header>
|
||||||
|
<x-mail::header :url="config('app.url')">
|
||||||
|
{{ config('blog.publishing_name') }}
|
||||||
|
</x-mail::header>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
{{-- Body --}}
|
||||||
|
{{ $slot }}
|
||||||
|
|
||||||
|
{{-- Subcopy --}}
|
||||||
|
@isset($subcopy)
|
||||||
|
<x-slot:subcopy>
|
||||||
|
<x-mail::subcopy>
|
||||||
|
{{ $subcopy }}
|
||||||
|
</x-mail::subcopy>
|
||||||
|
</x-slot:subcopy>
|
||||||
|
@endisset
|
||||||
|
|
||||||
|
{{-- Footer --}}
|
||||||
|
<x-slot:footer>
|
||||||
|
<x-mail::footer>
|
||||||
|
© {{ date('Y') }} {{ config('blog.publishing_name') }}. @lang('All rights reserved.')
|
||||||
|
</x-mail::footer>
|
||||||
|
</x-slot:footer>
|
||||||
|
</x-mail::layout>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td class="panel-content">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td class="panel-item">
|
||||||
|
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="table">
|
||||||
|
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,297 @@
|
||||||
|
/* Base */
|
||||||
|
|
||||||
|
body,
|
||||||
|
body *:not(html):not(style):not(br):not(tr):not(code) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
|
||||||
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #718096;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote {
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #3869d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
a img {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #3d4852;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5em;
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sub {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
background-color: #edf2f7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header a {
|
||||||
|
color: #550B73;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 75px;
|
||||||
|
max-height: 75px;
|
||||||
|
width: 75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
|
||||||
|
.body {
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
background-color: #edf2f7;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-body {
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #e8e5ef;
|
||||||
|
border-radius: 2px;
|
||||||
|
border-width: 1px;
|
||||||
|
box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
width: 570px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subcopy */
|
||||||
|
|
||||||
|
.subcopy {
|
||||||
|
border-top: 1px solid #e8e5ef;
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subcopy p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
-premailer-width: 570px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
width: 570px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
color: #b0adc5;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #b0adc5;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
|
||||||
|
.table table {
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
margin: 30px auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
border-bottom: 1px solid #edeff2;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
color: #74787e;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-cell {
|
||||||
|
max-width: 100vw;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
|
||||||
|
.action {
|
||||||
|
-premailer-cellpadding: 0;
|
||||||
|
-premailer-cellspacing: 0;
|
||||||
|
-premailer-width: 100%;
|
||||||
|
margin: 30px auto;
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background-color: #550B73;
|
||||||
|
border-bottom: 8px solid #550B73;
|
||||||
|
border-left: 18px solid #550B73;
|
||||||
|
border-right: 18px solid #550B73;
|
||||||
|
border-top: 8px solid #550B73;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-blue {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-bottom: 8px solid #2d3748;
|
||||||
|
border-left: 18px solid #2d3748;
|
||||||
|
border-right: 18px solid #2d3748;
|
||||||
|
border-top: 8px solid #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-green,
|
||||||
|
.button-success {
|
||||||
|
background-color: #48bb78;
|
||||||
|
border-bottom: 8px solid #48bb78;
|
||||||
|
border-left: 18px solid #48bb78;
|
||||||
|
border-right: 18px solid #48bb78;
|
||||||
|
border-top: 8px solid #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-red,
|
||||||
|
.button-error {
|
||||||
|
background-color: #e53e3e;
|
||||||
|
border-bottom: 8px solid #e53e3e;
|
||||||
|
border-left: 18px solid #e53e3e;
|
||||||
|
border-right: 18px solid #e53e3e;
|
||||||
|
border-top: 8px solid #e53e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panels */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-left: #2d3748 solid 4px;
|
||||||
|
margin: 21px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
background-color: #edf2f7;
|
||||||
|
color: #718096;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content p {
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-item p:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
|
||||||
|
.break-all {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ $slot }}: {{ $url }}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ $slot }}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[{{ $slot }}]({{ $url }})
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{!! strip_tags($header ?? '') !!}
|
||||||
|
|
||||||
|
{!! strip_tags($slot) !!}
|
||||||
|
@isset($subcopy)
|
||||||
|
|
||||||
|
{!! strip_tags($subcopy) !!}
|
||||||
|
@endisset
|
||||||
|
|
||||||
|
{!! strip_tags($footer ?? '') !!}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<x-mail::layout>
|
||||||
|
{{-- Header --}}
|
||||||
|
<x-slot:header>
|
||||||
|
<x-mail::header :url="config('app.url')">
|
||||||
|
{{ config('blog.publishing_name') }}
|
||||||
|
</x-mail::header>
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
{{-- Body --}}
|
||||||
|
{{ $slot }}
|
||||||
|
|
||||||
|
{{-- Subcopy --}}
|
||||||
|
@isset($subcopy)
|
||||||
|
<x-slot:subcopy>
|
||||||
|
<x-mail::subcopy>
|
||||||
|
{{ $subcopy }}
|
||||||
|
</x-mail::subcopy>
|
||||||
|
</x-slot:subcopy>
|
||||||
|
@endisset
|
||||||
|
|
||||||
|
{{-- Footer --}}
|
||||||
|
<x-slot:footer>
|
||||||
|
<x-mail::footer>
|
||||||
|
© {{ date('Y') }} {{ config('blog.publishing_name') }}. @lang('All rights reserved.')
|
||||||
|
</x-mail::footer>
|
||||||
|
</x-slot:footer>
|
||||||
|
</x-mail::layout>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ $slot }}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ $slot }}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{{ $slot }}
|
||||||
|
|
@ -5,6 +5,54 @@
|
||||||
@section('content')
|
@section('content')
|
||||||
<div>
|
<div>
|
||||||
<h1>Writer's Dashboard</h1>
|
<h1>Writer's Dashboard</h1>
|
||||||
<a href="{{ route('new_article') }}"><button>New Article</button></a>
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h3>All Articles</h3>
|
||||||
|
|
||||||
|
<table id="article-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Subhead</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Published At</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
<th>First Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($articles as $article)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $article->id }}</td>
|
||||||
|
<td>{{ $article?->slug ?? '-' }}</td>
|
||||||
|
<td>{{ $article?->title ?? '-' }}</td>
|
||||||
|
<td>{{ $article?->subtitle ?? '-' }}</td>
|
||||||
|
<td>{{ $article?->author?->display_name ?? '-' }}</td>
|
||||||
|
<td>{{ $article?->published_at ?? '-' }}</td>
|
||||||
|
<td>{{ $article?->updated_at ?? '-' }}</td>
|
||||||
|
<td>{{ $article?->created_at ?? '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{{--
|
||||||
|
<a href="{{ route() }}">
|
||||||
|
<button type="button">Edit</button>
|
||||||
|
</a>
|
||||||
|
--}}
|
||||||
|
<a href="{{}}">
|
||||||
|
<button type="button">
|
||||||
|
Unpublish
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td>No Articles</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
@section('title', 'Writer Login')
|
@section('title', 'Writer Login')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<section class="bubble-panel">
|
<section id="writer-login">
|
||||||
<h2>Writer Login</h2>
|
<h2>Writer Login</h2>
|
||||||
<form action="{{ route('authenticate') }}" method="POST">
|
<form action="{{ route('authenticate') }}" method="POST">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
$articles = Article::with(['author'])->where('published_at', '<', now())->latest()->limit(5)->get();
|
$articles = Article::with(['author'])->where('published_at', '<', now())->latest()->limit(5)->get();
|
||||||
return view('guest.home', ['articles' => $articles]);
|
return view('guest.home', ['articles' => $articles, 'showSubscribeForm' => true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/test-insert', function () {
|
Route::get('/test-insert', function () {
|
||||||
|
|
@ -28,7 +28,7 @@ Route::get('/test-insert', function () {
|
||||||
|
|
||||||
Route::get('/all-articles', function () {
|
Route::get('/all-articles', function () {
|
||||||
$all_articles = Article::where('published_at', '<', now())->paginate(25);
|
$all_articles = Article::where('published_at', '<', now())->paginate(25);
|
||||||
return view('guest.all-articles', ['all_articles' => $all_articles]);
|
return view('guest.all-articles', ['all_articles' => $all_articles, 'showSubscribeForm' => true]);
|
||||||
})->name('all-articles');
|
})->name('all-articles');
|
||||||
|
|
||||||
Route::get('/post/{article_slug}', function ($article_slug) {
|
Route::get('/post/{article_slug}', function ($article_slug) {
|
||||||
|
|
@ -40,7 +40,7 @@ Route::get('/post/{article_slug}', function ($article_slug) {
|
||||||
if(empty($active_article)) {
|
if(empty($active_article)) {
|
||||||
return abort(404);
|
return abort(404);
|
||||||
}
|
}
|
||||||
return view('guest.article', ['article' => $active_article]);
|
return view('guest.article', ['article' => $active_article, 'showSubscribeForm' => true]);
|
||||||
})->name('article');
|
})->name('article');
|
||||||
|
|
||||||
Route::get('/writer/login', function () {
|
Route::get('/writer/login', function () {
|
||||||
|
|
@ -57,6 +57,27 @@ Route::get('/subscribe-fail', function() {
|
||||||
return view('guest.subscribe-fail');
|
return view('guest.subscribe-fail');
|
||||||
})->name('subscribe-fail');
|
})->name('subscribe-fail');
|
||||||
|
|
||||||
|
Route::get('/unsubscribe', function(Request $request) {
|
||||||
|
return view('guest.unsubscribe', ['email' => $request->query('email')]);
|
||||||
|
})->name('unsubscribe');
|
||||||
|
|
||||||
|
Route::post('/unsubscribe', [ReaderController::class, 'unsubscribe'])->name('unsubscribe-save');
|
||||||
|
|
||||||
|
Route::get('/unsubscribe-success', function() {
|
||||||
|
return view('guest.unsubscribe-success');
|
||||||
|
})->name('unsubscribe-success');
|
||||||
|
|
||||||
|
Route::get('/unsubscribe-fail', function() {
|
||||||
|
return view('guest.unsubscribe-fail');
|
||||||
|
})->name('unsubscribe-fail');
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------
|
||||||
|
* WRITER ENDPOINTS
|
||||||
|
* ----------------
|
||||||
|
*/
|
||||||
|
|
||||||
Route::post('/writer/authenticate', [WriterController::class, 'authenticate'])->name('authenticate');
|
Route::post('/writer/authenticate', [WriterController::class, 'authenticate'])->name('authenticate');
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue