Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Http/Controllers/Articles/ArticlesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public function __construct()

public function index()
{
return view('articles.index');
}

public function show(Article $article)
Expand Down
70 changes: 70 additions & 0 deletions app/Http/Livewire/ShowArticles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace App\Http\Livewire;

use App\Models\Article;
use App\Models\Tag;
use Illuminate\View\View;
use Livewire\Component;
use Livewire\WithPagination;

final class ShowArticles extends Component
{
use WithPagination;

public $tag;

public $sortBy = 'recent';

protected $updatesQueryString = [
'tag' => ['except' => ''],
'sortBy' => ['except' => 'recent'],
];

public function mount(): void
{
$this->toggleTag(request()->query('tag', $this->tag));
$this->sortBy(request()->query('sortBy') ?: $this->sortBy);
}

public function render(): View
{
$articles = Article::published();

if ($this->tag) {
$articles->forTag($this->tag);
}

$articles->{$this->sortBy}();

return view('livewire.show-articles', [
'articles' => $articles->paginate(10),
'selectedTag' => $this->tag,
'selectedSortBy' => $this->sortBy,
]);
}

public function toggleTag($tag): void
{
$this->tag = $this->tag !== $tag && $this->tagExists($tag) ? $tag : null;
}

public function sortBy($sort): void
{
$this->sortBy = $this->validSort($sort) ? $sort : 'recent';
}

public function tagExists($tag): bool
{
return Tag::where('slug', $tag)->exists();
}

public function validSort($sort): bool
{
return in_array($sort, [
'recent',
'popular',
'trending',
]);
}
}
41 changes: 41 additions & 0 deletions app/Models/Article.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

final class Article extends Model
{
Expand Down Expand Up @@ -48,6 +49,11 @@ public function body(): string
return $this->body;
}

public function excerpt(int $limit = 100): string
{
return Str::limit(strip_tags(md_to_html($this->body())), $limit);
}

public function originalUrl(): ?string
{
return $this->original_url;
Expand Down Expand Up @@ -103,6 +109,13 @@ public function isNotPublished(): bool
return is_null($this->published_at);
}

public function readTime()
{
$minutes = round(str_word_count($this->body()) / 200);

return $minutes == 0 ? 1 : $minutes;
}

public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at');
Expand All @@ -112,4 +125,32 @@ public function scopeNotPublished(Builder $query): Builder
{
return $query->whereNull('published_at');
}

public function scopeForTag(Builder $query, string $tag): Builder
{
return $query->whereHas('tagsRelation', function ($query) use ($tag) {
$query->where('tags.slug', $tag);
});
}

public function scopeRecent(Builder $query): Builder
{
return $query->orderBy('published_at', 'desc');
}

public function scopePopular(Builder $query): Builder
{
return $query->withCount('likes')
->orderBy('likes_count', 'desc')
->orderBy('published_at', 'desc');
}

public function scopeTrending(Builder $query): Builder
{
return $query->withCount(['likes' => function ($query) {
$query->where('created_at', '>=', now()->subWeek());
}])
->orderBy('likes_count', 'desc')
->orderBy('published_at', 'desc');
}
}
11 changes: 11 additions & 0 deletions app/Models/Tag.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Helpers\HasSlug;
use App\Helpers\ModelHelpers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

final class Tag extends Model
{
Expand All @@ -30,4 +31,14 @@ public function name(): string
{
return $this->name;
}

public function slug(): string
{
return $this->slug;
}

public function articles(): MorphToMany
{
return $this->morphedByMany(Article::class, 'taggable');
}
}
8 changes: 8 additions & 0 deletions resources/css/tags.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ ul.tags li.active {
@apply bg-gray-100 rounded font-bold;
}

ul.tags li.active button {
@apply font-bold;
}

ul.tags li button:focus {
@apply outline-none;
}

.thread-info-tags a:hover {
@apply no-underline;
}
23 changes: 3 additions & 20 deletions resources/views/articles/index.blade.php
Original file line number Diff line number Diff line change
@@ -1,24 +1,7 @@
@title('Community Articles')

@extends('layouts.default')

@section('content')
<div class="max-w-screen-md mx-auto p-4 pt-8">
<h1 class="text-4xl tracking-tight leading-10 font-extrabold text-gray-900 sm:leading-none mb-4">{{ $article->title() }}</h1>
<div class="mr-6 mb-8 text-gray-700 flex items-center">
@include('forum.threads.info.avatar', ['user' => $article->author(), 'size' => 50])
<div>
<a href="{{ route('profile', $article->author()->username()) }}"
class="text-green-darker mr-2">
{{ $article->author()->name() }}
</a>
<span class="block text-sm">
{{ $article->createdAt()->format('j M, Y') }} - {{ $article->readTime() }} min read</span>
</div>
</div>
<div
class="article text-lg"
x-data="{}"
x-init="function () { highlightCode($el); }"
x-html="{{ json_encode(md_to_html($article->body())) }}"
>
</div>
<livewire:show-articles>
@endsection
96 changes: 96 additions & 0 deletions resources/views/livewire/show-articles.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<div class="container mx-auto px-4 pt-4 flex flex-wrap flex-col-reverse lg:flex-row">
<div class="w-full lg:w-3/4 py-8 lg:pr-4">
<div wire:loading class="flex w-full h-full text-2xl text-gray-700">
Loading...
</div>

@foreach($articles as $article)
<div class="pb-8 mb-8 border-b-2">
<div>
@foreach($article->tags() as $tag)
<button class="inline-block focus:outline-none rounded-full {{ $tag->slug() === $selectedTag ? 'bg-green-primary text-white shadow-outline-green' : 'bg-green-light text-green-primary' }}" wire:click="toggleTag('{{ $tag->slug() }}')">
<span class="inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium leading-5">
{{ $tag->name() }}
</span>
</button>
@endforeach
</div>
<a href="{{ route('articles.show', $article->slug()) }}" class="block">
<h3 class="mt-4 text-xl leading-7 font-semibold text-gray-900">
{{ $article->title() }}
</h3>
<p class="mt-3 text-base leading-6 text-gray-500">
{{ $article->excerpt() }}
</p>
</a>

<div class="flex items-center justify-between mt-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<a href="#">
<img class="h-10 w-10 rounded-full" src="{{ $article->author()->gravatarUrl($avatarSize ?? 250) }}" alt="{{ $article->author()->name }}" />
</a>
</div>
<div class="ml-3">
<p class="text-sm leading-5 font-medium text-gray-900">
<a href="#">
{{ $article->author()->name() }}
</a>
</p>
<div class="flex text-sm leading-5 text-gray-500">
<time datetime="{{ $article->publishedAt()->format('Y-m-d') }}">
{{ $article->publishedAt()->format('j M, Y') }}
</time>
<span class="mx-1">
&middot;
</span>
<span>
{{ $article->readTime() }} min read
</span>
</div>
</div>
</div>
<div class="flex items-center text-gray-500">
<svg fill="currentColor" viewBox="0 0 20 20" class="w-5 h-5 mr-2">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" fill-rule="evenodd"></path>
</svg>
{{ $article->likesCount() }}
</div>
</div>
</div>
@endforeach

{{ $articles->links() }}

</div>

<div class="w-full lg:w-1/4 lg:pt-8 lg:pl-4">
<span class="relative z-0 inline-flex shadow-sm mb-8">
<button wire:click="sortBy('recent')" type="button" class="relative inline-flex items-center px-4 py-2 rounded-l-md border text-sm leading-5 font-medium focus:z-10 focus:outline-none focus:border-green-light focus:shadow-outline-green active:bg-green-primary active:text-white transition ease-in-out duration-150 {{ $selectedSortBy === 'recent' ? 'bg-green-primary text-white border-green-primary shadow-outline-green z-10' : 'bg-white text-gray-700 border-gray-300' }}">
Recent
</button>
<button wire:click="sortBy('popular')" type="button" class="-ml-px relative inline-flex items-center px-4 py-2 border text-sm leading-5 font-medium focus:z-10 focus:outline-none focus:border-green-light focus:shadow-outline-green active:bg-green-primary active:text-white transition ease-in-out duration-150 {{ $selectedSortBy === 'popular' ? 'bg-green-primary text-white border-green-primary shadow-outline-green z-10' : 'bg-white text-gray-700 border-gray-300' }}">
Popular
</button>
<button wire:click="sortBy('trending')" type="button" class="-ml-px relative inline-flex items-center px-4 py-2 rounded-r-md border text-sm leading-5 font-medium focus:z-10 focus:outline-none focus:border-green-light focus:shadow-outline-green active:bg-green-primary active:text-white transition ease-in-out duration-150 {{ $selectedSortBy === 'trending' ? 'bg-green-primary text-white border-green-primary shadow-outline-green z-10' : 'bg-white text-gray-700 border-gray-300' }}">
Trending 🔥
</button>
</span>

<ul class="tags">
<li class="{{ ! $selectedTag ? ' active' : '' }}">
<button wire:click="toggleTag('')">
All
</button>
</li>

@foreach (App\Models\Tag::whereHas('articles')->orderBy('name')->get() as $tag)
<li class="{{ $selectedTag === $tag->slug() ? ' active' : '' }}">
<button wire:click="toggleTag('{{ $tag->slug() }}')">
{{ $tag->name() }}
</button>
</li>
@endforeach
</ul>
</div>
</div>
35 changes: 35 additions & 0 deletions tests/Feature/ArticleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace Tests\Feature;

use App\Http\Livewire\ShowArticles;
use App\Models\Article;
use App\Models\Series;
use App\Models\Tag;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Livewire\Livewire;

class ArticleTest extends BrowserKitTestCase
{
Expand Down Expand Up @@ -372,4 +374,37 @@ public function draft_articles_cannot_be_viewed_by_logged_in_users()
$this->get('/articles/my-first-article')
->assertResponseStatus(404);
}

/** @test */
public function sort_parameters_are_set_correctly()
{
Livewire::test(ShowArticles::class)
->assertSet('sortBy', 'recent')
->call('sortBy', 'popular')
->assertSet('sortBy', 'popular')
->call('sortBy', 'trending')
->assertSet('sortBy', 'trending')
->call('sortBy', 'recent')
->assertSet('sortBy', 'recent');
}

/** @test */
public function tags_can_be_toggled()
{
$tag = factory(Tag::class)->create();

Livewire::test(ShowArticles::class)
->call('toggleTag', $tag->slug)
->assertSet('tag', $tag->slug)
->call('toggleTag', $tag->slug)
->assertSet('tag', null);
}

/** @test */
public function invalid_sort_parameter_defaults_to_recent()
{
Livewire::test(ShowArticles::class)
->call('sortBy', 'something-invalid')
->assertSet('sortBy', 'recent');
}
}
Loading