Skip to content

Commit 30ca375

Browse files
committed
List articles
1 parent 9504a86 commit 30ca375

File tree

9 files changed

+323
-20
lines changed

9 files changed

+323
-20
lines changed

app/Http/Controllers/Articles/ArticlesController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public function __construct()
2424

2525
public function index()
2626
{
27+
return view('articles.index');
2728
}
2829

2930
public function show(Article $article)

app/Http/Livewire/ShowArticles.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace App\Http\Livewire;
4+
5+
use App\Models\Article;
6+
use App\Models\Tag;
7+
use Illuminate\View\View;
8+
use Livewire\Component;
9+
use Livewire\WithPagination;
10+
11+
final class ShowArticles extends Component
12+
{
13+
use WithPagination;
14+
15+
public $tag;
16+
17+
public $sortBy = 'recent';
18+
19+
protected $updatesQueryString = [
20+
'tag' => ['except' => ''],
21+
'sortBy' => ['except' => 'recent'],
22+
];
23+
24+
public function mount(): void
25+
{
26+
$this->toggleTag(request()->query('tag', $this->tag));
27+
$this->sortBy(request()->query('sortBy') ?: $this->sortBy);
28+
}
29+
30+
public function render(): View
31+
{
32+
$articles = Article::published();
33+
34+
if ($this->tag) {
35+
$articles->forTag($this->tag);
36+
}
37+
38+
$articles->{$this->sortBy}();
39+
40+
return view('livewire.show-articles', [
41+
'articles' => $articles->paginate(10),
42+
'selectedTag' => $this->tag,
43+
'selectedSortBy' => $this->sortBy,
44+
]);
45+
}
46+
47+
public function toggleTag($tag): void
48+
{
49+
$this->tag = $this->tag !== $tag && $this->tagExists($tag) ? $tag : null;
50+
}
51+
52+
public function sortBy($sort): void
53+
{
54+
$this->sortBy = $this->validSort($sort) ? $sort : 'recent';
55+
}
56+
57+
public function tagExists($tag): bool
58+
{
59+
return Tag::where('slug', $tag)->exists();
60+
}
61+
62+
public function validSort($sort): bool
63+
{
64+
return in_array($sort, [
65+
'recent',
66+
'popular',
67+
'trending',
68+
]);
69+
}
70+
}

app/Models/Article.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Carbon\Carbon;
1111
use Illuminate\Database\Eloquent\Builder;
1212
use Illuminate\Database\Eloquent\Model;
13+
use Illuminate\Support\Str;
1314

1415
final class Article extends Model
1516
{
@@ -48,6 +49,11 @@ public function body(): string
4849
return $this->body;
4950
}
5051

52+
public function excerpt(int $limit = 100): string
53+
{
54+
return Str::limit(strip_tags(md_to_html($this->body())), $limit);
55+
}
56+
5157
public function originalUrl(): ?string
5258
{
5359
return $this->original_url;
@@ -103,6 +109,13 @@ public function isNotPublished(): bool
103109
return is_null($this->published_at);
104110
}
105111

112+
public function readTime()
113+
{
114+
$minutes = round(str_word_count($this->body()) / 200);
115+
116+
return $minutes == 0 ? 1 : $minutes;
117+
}
118+
106119
public function scopePublished(Builder $query): Builder
107120
{
108121
return $query->whereNotNull('published_at');
@@ -112,4 +125,32 @@ public function scopeNotPublished(Builder $query): Builder
112125
{
113126
return $query->whereNull('published_at');
114127
}
128+
129+
public function scopeForTag(Builder $query, string $tag): Builder
130+
{
131+
return $query->whereHas('tagsRelation', function ($query) use ($tag) {
132+
$query->where('tags.slug', $tag);
133+
});
134+
}
135+
136+
public function scopeRecent(Builder $query): Builder
137+
{
138+
return $query->orderBy('published_at', 'desc');
139+
}
140+
141+
public function scopePopular(Builder $query): Builder
142+
{
143+
return $query->withCount('likes')
144+
->orderBy('likes_count', 'desc')
145+
->orderBy('published_at', 'desc');
146+
}
147+
148+
public function scopeTrending(Builder $query): Builder
149+
{
150+
return $query->withCount(['likes' => function ($query) {
151+
$query->where('created_at', '>=', now()->subWeek());
152+
}])
153+
->orderBy('likes_count', 'desc')
154+
->orderBy('published_at', 'desc');
155+
}
115156
}

app/Models/Tag.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Helpers\HasSlug;
66
use App\Helpers\ModelHelpers;
77
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\MorphToMany;
89

910
final class Tag extends Model
1011
{
@@ -30,4 +31,14 @@ public function name(): string
3031
{
3132
return $this->name;
3233
}
34+
35+
public function slug(): string
36+
{
37+
return $this->slug;
38+
}
39+
40+
public function articles(): MorphToMany
41+
{
42+
return $this->morphedByMany(Article::class, 'taggable');
43+
}
3344
}

resources/css/tags.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ ul.tags li.active {
66
@apply bg-gray-100 rounded font-bold;
77
}
88

9+
ul.tags li.active button {
10+
@apply font-bold;
11+
}
12+
13+
ul.tags li button:focus {
14+
@apply outline-none;
15+
}
16+
917
.thread-info-tags a:hover {
1018
@apply no-underline;
1119
}
Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,7 @@
1+
@title('Community Articles')
2+
13
@extends('layouts.default')
24

35
@section('content')
4-
<div class="max-w-screen-md mx-auto p-4 pt-8">
5-
<h1 class="text-4xl tracking-tight leading-10 font-extrabold text-gray-900 sm:leading-none mb-4">{{ $article->title() }}</h1>
6-
<div class="mr-6 mb-8 text-gray-700 flex items-center">
7-
@include('forum.threads.info.avatar', ['user' => $article->author(), 'size' => 50])
8-
<div>
9-
<a href="{{ route('profile', $article->author()->username()) }}"
10-
class="text-green-darker mr-2">
11-
{{ $article->author()->name() }}
12-
</a>
13-
<span class="block text-sm">
14-
{{ $article->createdAt()->format('j M, Y') }} - {{ $article->readTime() }} min read</span>
15-
</div>
16-
</div>
17-
<div
18-
class="article text-lg"
19-
x-data="{}"
20-
x-init="function () { highlightCode($el); }"
21-
x-html="{{ json_encode(md_to_html($article->body())) }}"
22-
>
23-
</div>
6+
<livewire:show-articles>
247
@endsection
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<div class="container mx-auto px-4 pt-4 flex flex-wrap flex-col-reverse md:flex-row">
2+
<div class="w-full lg:w-1/4 lg:pt-8 pr-4">
3+
<span class="relative z-0 inline-flex shadow-sm mb-8">
4+
<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' }}">
5+
Recent
6+
</button>
7+
<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' }}">
8+
Popular
9+
</button>
10+
<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' }}">
11+
Trending 🔥
12+
</button>
13+
</span>
14+
15+
<ul class="tags">
16+
<li class="{{ ! $selectedTag ? ' active' : '' }}">
17+
<button wire:click="toggleTag('')">
18+
All
19+
</button>
20+
</li>
21+
22+
@foreach (App\Models\Tag::whereHas('articles')->orderBy('name')->get() as $tag)
23+
<li class="{{ $selectedTag === $tag->slug() ? ' active' : '' }}">
24+
<button wire:click="toggleTag('{{ $tag->slug() }}')">
25+
{{ $tag->name() }}
26+
</button>
27+
</li>
28+
@endforeach
29+
</ul>
30+
</div>
31+
<div class="w-full lg:w-3/4 py-8 pl-4">
32+
<div wire:loading class="flex w-full h-full text-2xl text-gray-700">
33+
Loading...
34+
</div>
35+
36+
@foreach($articles as $article)
37+
<div class="pb-8 mb-8 border-b-2">
38+
<div>
39+
@foreach($article->tags() as $tag)
40+
<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() }}')">
41+
<span class="inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium leading-5">
42+
{{ $tag->name() }}
43+
</span>
44+
</button>
45+
@endforeach
46+
</div>
47+
<a href="{{ route('articles.show', $article->slug()) }}" class="block">
48+
<h3 class="mt-4 text-xl leading-7 font-semibold text-gray-900">
49+
{{ $article->title() }}
50+
</h3>
51+
<p class="mt-3 text-base leading-6 text-gray-500">
52+
{{ $article->excerpt() }}
53+
</p>
54+
</a>
55+
56+
<div class="flex items-center justify-between mt-6">
57+
<div class="flex items-center">
58+
<div class="flex-shrink-0">
59+
<a href="#">
60+
<img class="h-10 w-10 rounded-full" src="{{ $article->author()->gravatarUrl($avatarSize ?? 250) }}" alt="{{ $article->author()->name }}" />
61+
</a>
62+
</div>
63+
<div class="ml-3">
64+
<p class="text-sm leading-5 font-medium text-gray-900">
65+
<a href="#">
66+
{{ $article->author()->name() }}
67+
</a>
68+
</p>
69+
<div class="flex text-sm leading-5 text-gray-500">
70+
<time datetime="{{ $article->publishedAt()->format('Y-m-d') }}">
71+
{{ $article->publishedAt()->format('j M, Y') }}
72+
</time>
73+
<span class="mx-1">
74+
&middot;
75+
</span>
76+
<span>
77+
{{ $article->readTime() }} min read
78+
</span>
79+
</div>
80+
</div>
81+
</div>
82+
<div class="flex items-center text-gray-500">
83+
<svg fill="currentColor" viewBox="0 0 20 20" class="w-5 h-5 mr-2">
84+
<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>
85+
</svg>
86+
{{ $article->likesCount() }}
87+
</div>
88+
</div>
89+
</div>
90+
@endforeach
91+
92+
{{ $articles->links() }}
93+
94+
</div>
95+
</div>

tests/Feature/ArticleTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
namespace Tests\Feature;
44

5+
use App\Http\Livewire\ShowArticles;
56
use App\Models\Article;
67
use App\Models\Series;
78
use App\Models\Tag;
89
use Illuminate\Foundation\Testing\DatabaseMigrations;
10+
use Livewire\Livewire;
911

1012
class ArticleTest extends BrowserKitTestCase
1113
{
@@ -372,4 +374,37 @@ public function draft_articles_cannot_be_viewed_by_logged_in_users()
372374
$this->get('/articles/my-first-article')
373375
->assertResponseStatus(404);
374376
}
377+
378+
/** @test */
379+
public function sort_parameters_are_set_correctly()
380+
{
381+
Livewire::test(ShowArticles::class)
382+
->assertSet('sortBy', 'recent')
383+
->call('sortBy', 'popular')
384+
->assertSet('sortBy', 'popular')
385+
->call('sortBy', 'trending')
386+
->assertSet('sortBy', 'trending')
387+
->call('sortBy', 'recent')
388+
->assertSet('sortBy', 'recent');
389+
}
390+
391+
/** @test */
392+
public function tags_can_be_toggled()
393+
{
394+
$tag = factory(Tag::class)->create();
395+
396+
Livewire::test(ShowArticles::class)
397+
->call('toggleTag', $tag->slug)
398+
->assertSet('tag', $tag->slug)
399+
->call('toggleTag', $tag->slug)
400+
->assertSet('tag', null);
401+
}
402+
403+
/** @test */
404+
public function invalid_sort_parameter_defaults_to_recent()
405+
{
406+
Livewire::test(ShowArticles::class)
407+
->call('sortBy', 'something-invalid')
408+
->assertSet('sortBy', 'recent');
409+
}
375410
}

0 commit comments

Comments
 (0)