iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 16
0

https://ithelp.ithome.com.tw/upload/images/20200929/20113602vN9IGvSk73.jpg

再來要做 Lightning 的文章頁面,上篇新增的文章終於可以看到了。

文章頁面

文章頁面比較複雜一點點,我們把它抽出一個單獨的 ShowPost Controller,加上 -i 會新增 Single Action Controller

php artisan make:controller Post/ShowPost -i

要先排除掉 Resource 裡的 show 路由,和新增新的路由:

routes/web.php

Route::resource('posts', 'Post\PostController')->except('show');
Route::get('posts/{post}', 'Post\ShowPost');

顧名思義,Single Action Controller 裡面只有一個方法,這個方法要取名 __invoke()

app/Http/Controllers/Post/ShowPost.php

public function __invoke(Post $post)
{
    $post->increment('visits');

    return Inertia::render('Post/Show', [
        'post' => PostPresenter::make($post)->get(),
    ]);
}

在 Post 裡有個紀錄瀏覽次數的欄位 visits,可以使用 increment() 讓每次瀏覽都會自動加1。

可是 PostPresenter 裡的欄位沒有文章內容 content 和文章作者欄位,這裡要介紹一個 Flexible Presenter 的功能 preset(),例如在 PostPresenter 定義一個 presetShow() 方法,在呼叫處就可以用 ->preset('show') 呼叫此方法。先串接自訂的 preset:

app/Http/Controllers/Post/ShowPost.php

PostPresenter::make($post)
    ->preset('show')
    ->get()

而這個自訂的 preset 可以做些什麼呢?可以串 only()except()with(),前兩個就是指定欄位和排除欄位,不過現在要用的是 with()with() 可以增加自訂欄位,接收一個閉包函數,函數的參數是在 Presenter::make() 丟進去的 Model (Post)。

然後我們就可以增加缺失的 content (文章內容) & author (文章作者),作者即關聯的 User Model,同樣可以套 UserPresenter:

app/Presenters/PostPresenter.php

public function presetShow()
{
    return $this->with(fn (Post $post) => [
        'content' => $post->content,
        'author' => fn () => UserPresenter::make($post->author)
            ->preset('withCount')
            ->get(),
    ]);
}

這裡的 UserPresenter 也套用了 preset,同樣也要定義 presetWithCount()postsCount 欄位是作者全部文章的數量:

app/Presenters/UserPresenter.php

public function presetWithCount()
{
    return $this->with(fn (User $user) => [
        'postsCount' => $user->posts()->count(),
    ]);
}

然後前端文章頁面:

這裡說一下英文單字太長會破版的坑,通常有些英文單字太長時,可以使用 overflow-wrap: break-word (Tailwind CSS 對應 .break-words) 來強制段行。但這裡做完之後,開手機板的寬度還是破版,因為這裡用 Grid 排版,踩到了 Grid 的坑 (Flex 也會遇到此問題),解決方案之一是在 Grid 的元素的子元素,加上 min-width: 0 (Tailwind CSS 對應 .min-w-0)。參考:Preventing a Grid Blowout

resources/js/Pages/Post/Show.vue

<template>
  <div class="py-6 md:py-8">
    <alert v-if="$page.flash.success" class="shadow mb-6">{{ $page.flash.success }}</alert>

    <div class="grid gap-6 xl:grid-cols-4">
      <div class="card p-6 md:p-8 min-w-0 xl:col-span-3">
        <h1 class="text-3xl font-semibold leading-snug">{{ post.title }}</h1>
        <div class="flex space-x-4 mt-2 text-sm">
          <div>
            <icon class="text-purple-500" icon="heroicons-outline:clock" />
            <span class="text-gray-500">{{ post.created_at }}</span>
          </div>
          <div>
            <icon class="text-purple-500" icon="heroicons-outline:eye" />
            <span class="text-gray-500">{{ post.visits }}</span>
          </div>
        </div>

        <div class="mt-6 font-light break-words">{{ post.content }}</div>
      </div>

      <div>
        <div class="card p-6 md:p-8 sticky top-8">
          <inertia-link :href="`/user/${post.author.id}`">
            <img :src="post.author.avatar" class="rounded-full w-20 h-20 mx-auto">
          </inertia-link>
          <div class="mt-4 text-center">
            <div class="text-2xl font-semibold">
              <inertia-link :href="`/user/${post.author.id}`" class="hover:text-purple-500">
                {{ post.author.name }}
              </inertia-link>
            </div>
            <div v-if="post.author.description" class="mt-2 text-gray-600 font-light">
              {{ post.author.description }}
            </div>
            <div class="flex justify-center items-center space-x-6 mt-3">
              <inertia-link :href="`/user/${post.author.id}`" class="link font-light">
                <icon icon="heroicons-outline:book-open" />
                文章 {{ post.author.postsCount }}
              </inertia-link>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import AppLayout from '@/Layouts/AppLayout'
import Alert from '@/Components/Alert'

export default {
  layout: AppLayout,
  metaInfo() {
    this.addMeta('description', this.post.description)
    this.addMetaWithProperty('og:type', 'website')
    this.addMetaWithProperty('og:title', this.post.title)
    this.addMetaWithProperty('og:description', this.post.description)
    this.addMetaWithProperty('og:image', this.post.thumbnail)
    this.addMetaWithProperty('og:url', location.href)
    this.addMeta('twitter:title', this.post.title)
    this.addMeta('twitter:description', this.post.description)
    this.addMeta('twitter:url', location.href)
    this.addMeta('twitter:card', 'summary_large_image')
    this.addMeta('twitter:image', this.post.thumbnail)

    return {
      title: this.post.title,
      meta: this.meta
    }
  },
  components: {
    Alert
  },
  props: {
    post: Object
  },
  data() {
    return {
      meta: []
    }
  },
  methods: {
    addMeta(name, content) {
      if (content) this.meta.push({ name, content })
    },
    addMetaWithProperty(property, content) {
      if (content) this.meta.push({ property, content })
    }
  }
}
</script>

/posts/1 就可以看到新增的第1篇文章了:

https://ithelp.ithome.com.tw/upload/images/20200918/20113602RcxT5WIKSP.jpg

文章要分享,SEO 自然不能少,Meta 交給 Vue Meta 了。可是 Vue Meta 只會在前端渲染啊!沒關係,之後會解決。

文章頁面顯示授權 - Policy

文章還有個欄位叫 published (發布),可以讓文章作者決定要不要發布文章,正常在未發布(草稿)時,應該是除了作者的其他人就不能看該文章。所以現在要來新增 PostPolicy:

php artisan make:policy PostPolicy --model=Post

view() 方法就是管顯示單個資源的授權,為了兼容未登入使用者這裡要用 ?User 標示為可選,$user 也要包 optional()。若此用戶有登入且為作者,不管是不是草稿,都可以正常瀏覽。若不是就只能瀏覽已發布的文章:

app/Policies/PostPolicy.php

public function view(?User $user, Post $post)
{
    return optional($user)->id === $post->author_id
        ? true
        : $post->published === true;
}

還有在 ShowPost Controller 裡增加 authorize() 來驗證用戶:

app/Http/Controllers/Post/ShowPost.php

public function __invoke(Post $post)
{
    $this->authorize('view', $post);
    ...
}

文章發布狀態

剛才的文章數量是直接拿作者所有文章的數量,包括草稿,但草稿數不需要讓其他用戶知道,所以要改一下:

app/Presenters/UserPresenter.php

public function presetWithCount()
{
    return $this->with(fn (User $user) => [
        'postsCount' => $user->publishedPosts()->count(),
    ]);
}

阿這 publishedPosts() 是不存在的ㄝ,要去 User Model 裡增加:

app/User.php

/**
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function publishedPosts()
{
    return $this->posts()->published();
}

published() 也不存在,去 Post Model 定義 scope,在組 Query Builder 時就可以呼叫,順帶連 unpublished() 也一起加上:

app/Post.php

public function scopePublished($query)
{
    return $query->where('published', true);
}

public function scopeUnpublished($query)
{
    return $query->where('published', false);
}

然後開 Tinker 把文章設定成未發布:

php artisan tinker
>>> App\Post::find(1)->update(['published' => false])

用無痕模式開 /posts/1 看看:

https://ithelp.ithome.com.tw/upload/images/20200918/2011360290kLPTTqs0.jpg

嗯!正常。改成發布狀態:

>>> App\Post::find(1)->update(['published' => true])

再用無痕模式打開:

https://ithelp.ithome.com.tw/upload/images/20200918/20113602xF1lDpsNbH.jpg

現在我們就可以在頁面裡增加一個草稿的標示,讓作者知道這篇文章尚未發布:

resources/js/Pages/Post/Show.vue

<h1 class="text-3xl font-semibold leading-snug">{{ post.title }}</h1>
<div class="flex space-x-4 mt-2 text-sm">
  ...
  <div v-if="!post.published">
    <span class="px-2 py-1 bg-green-100 text-green-700">草稿</span>
  </div>
</div>

https://ithelp.ithome.com.tw/upload/images/20200918/20113602EO6Lzfj7w9.jpg

完成!

優化瀏覽人次

最後要講的是優化瀏覽人次,現在如果你一直重新整理頁面,瀏覽次數就會一直飆升,且容易被灌水。這裡我們可以增加一些條件。需要是非作者的其他讀者,且沒有瀏覽過的 Session 紀錄,才會增加瀏覽次數:

public function __invoke(Post $post)
{
    ...
    $this->incrementVisit($post);
    ...
}

protected function incrementVisit(Post $post)
{
    if (! optional($this->user())->can('view', $post) &&
        ! session("posts:visits:{$post->id}")
    ) {
        $post->increment('visits');
        session()->put("posts:visits:{$post->id}", true);
    }
}

現在在無痕模式打開文章,瀏覽次數只會+1,且重新整理不會一直增加瀏覽人次。

總結

現在至少有個簡單的文章頁面,新增的文章終於可以看了。當然我們還不滿足,下篇我們改成用 Markdown,大家很熟悉的語法,敬請期待~~

Lightning 範例程式碼:https://github.com/ycs77/lightning

參考資料


上一篇
Day 15 Lightning 新增文章
下一篇
Day 17 配置 Markdown
系列文
關於我用 Laravel 寫 SPA 卻不寫 API 的那檔事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言