Add headless CMS for official site content management

Integrate article and page management into the Laravel admin dashboard
to serve as a headless CMS for the Next.js frontend (usher-site).

Backend:
- 7 migrations: article_categories, article_tags, articles, pivots, attachments, pages
- 5 models with relationships: Article, ArticleCategory, ArticleTag, ArticleAttachment, Page
- 4 admin controllers: articles (with publish/archive/pin), categories, tags, pages
- Admin views with EasyMDE markdown editor, multi-select categories/tags
- Navigation section "官網管理" in admin sidebar

API (v1):
- GET /api/v1/articles (filtered by type, category, tag, search; paginated)
- GET /api/v1/articles/{slug} (with related articles)
- GET /api/v1/categories
- GET /api/v1/pages/{slug} (with children)
- GET /api/v1/homepage (aggregated homepage data)
- Attachment download endpoint
- CORS configured for usher.org.tw, vercel.app, localhost:3000

Content migration:
- ImportHugoContent command: imports Hugo markdown files as articles/pages
- Successfully imported 27 articles, 17 categories, 11 tags, 9 pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 11:58:22 +08:00
parent bfbec861d0
commit a30af8eaf7
45 changed files with 4816 additions and 31 deletions

View File

@@ -0,0 +1,135 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
編輯頁面
</h2>
<a href="{{ route('admin.pages.show', $page) }}" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
返回查看
</a>
</div>
</x-slot>
<div class="py-12">
<div class="mx-auto max-w-4xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<form method="POST" action="{{ route('admin.pages.update', $page) }}" class="space-y-6 p-6">
@csrf
@method('PATCH')
<!-- Title -->
<div>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">標題 <span class="text-red-500">*</span></label>
<input type="text" name="title" id="title" value="{{ old('title', $page->title) }}" required
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300">
@error('title')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<!-- Slug -->
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300">網址代碼</label>
<input type="text" name="slug" id="slug" value="{{ old('slug', $page->slug) }}"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300">
@error('slug')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<!-- Parent Page -->
<div>
<label for="parent_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">上層頁面</label>
<select name="parent_id" id="parent_id"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300">
<option value="">無(頂層頁面)</option>
@foreach($parentPages as $parentPage)
<option value="{{ $parentPage->id }}" {{ old('parent_id', $page->parent_id) == $parentPage->id ? 'selected' : '' }}>{{ $parentPage->title }}</option>
@endforeach
</select>
</div>
<!-- Content (Markdown Editor) -->
<div>
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300">內容 <span class="text-red-500">*</span></label>
<textarea name="content" id="content" rows="15" required>{{ old('content', $page->content) }}</textarea>
@error('content')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<!-- Template -->
<div>
<label for="template" class="block text-sm font-medium text-gray-700 dark:text-gray-300">模板</label>
<input type="text" name="template" id="template" value="{{ old('template', $page->template) }}"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
placeholder="例如homepage, about, contact">
</div>
<!-- Status -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">狀態</label>
<select name="status" id="status"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300">
<option value="draft" {{ old('status', $page->status) === 'draft' ? 'selected' : '' }}>草稿</option>
<option value="published" {{ old('status', $page->status) === 'published' ? 'selected' : '' }}>已發布</option>
</select>
</div>
<!-- Sort Order -->
<div>
<label for="sort_order" class="block text-sm font-medium text-gray-700 dark:text-gray-300">排序</label>
<input type="number" name="sort_order" id="sort_order" value="{{ old('sort_order', $page->sort_order) }}" min="0"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300">
</div>
<!-- Meta Description -->
<div>
<label for="meta_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">SEO 描述</label>
<textarea name="meta_description" id="meta_description" rows="2"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300">{{ old('meta_description', $page->meta_description) }}</textarea>
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-end space-x-3 border-t border-gray-200 dark:border-gray-700 pt-6">
<a href="{{ route('admin.pages.show', $page) }}" class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
取消
</a>
<button type="submit" class="rounded-md border border-transparent bg-indigo-600 dark:bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 dark:hover:bg-indigo-600">
儲存變更
</button>
</div>
</form>
</div>
</div>
</div>
@push('styles')
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
@endpush
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
autosave: {
enabled: true,
uniqueId: 'page-edit-{{ $page->id }}',
delay: 10000,
},
placeholder: '使用 Markdown 撰寫頁面內容...',
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', 'table', '|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
});
});
</script>
@endpush
</x-app-layout>