Add Tailwind typography plugin, enhance User model, update DatabaseSeeder, improve CSS styles, and define new routes for blog functionality

This commit is contained in:
Ethanfly 2025-12-24 16:40:52 +08:00
parent 196dd0db79
commit 509a4e91e5
44 changed files with 26944 additions and 13 deletions

View File

@ -0,0 +1,166 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\CategoryResource\Pages;
use App\Models\Category;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
public static function getNavigationIcon(): string|\BackedEnum|null
{
return 'heroicon-o-folder';
}
public static function getNavigationSort(): ?int
{
return 1;
}
public static function getNavigationGroup(): ?string
{
return '内容管理';
}
public static function getModelLabel(): string
{
return '分类';
}
public static function getPluralModelLabel(): string
{
return '分类';
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('基本信息')
->schema([
TextInput::make('name')
->label('分类名称')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->label('URL 别名')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Textarea::make('description')
->label('描述')
->rows(3)
->maxLength(500),
FileUpload::make('cover_image')
->label('封面图片')
->image()
->directory('categories')
->imageEditor(),
])
->columns(2),
Section::make('设置')
->schema([
TextInput::make('sort_order')
->label('排序')
->numeric()
->default(0),
Toggle::make('is_active')
->label('启用')
->default(true),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
ImageColumn::make('cover_image')
->label('封面')
->circular(),
TextColumn::make('name')
->label('名称')
->searchable()
->sortable(),
TextColumn::make('slug')
->label('别名')
->searchable(),
TextColumn::make('posts_count')
->label('文章数')
->counts('posts')
->sortable(),
TextColumn::make('sort_order')
->label('排序')
->sortable(),
IconColumn::make('is_active')
->label('状态')
->boolean(),
TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('sort_order')
->filters([
TernaryFilter::make('is_active')
->label('状态'),
])
->actions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
\Filament\Actions\BulkActionGroup::make([
\Filament\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\CategoryResource\Pages;
use App\Filament\Resources\CategoryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCategory extends CreateRecord
{
protected static string $resource = CategoryResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CategoryResource\Pages;
use App\Filament\Resources\CategoryResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\CategoryResource\Pages;
use App\Filament\Resources\CategoryResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCategories extends ListRecords
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,307 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PostResource\Pages;
use App\Models\Post;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class PostResource extends Resource
{
protected static ?string $model = Post::class;
public static function getNavigationIcon(): string|\BackedEnum|null
{
return 'heroicon-o-document-text';
}
public static function getNavigationSort(): ?int
{
return 0;
}
public static function getNavigationGroup(): ?string
{
return '内容管理';
}
public static function getModelLabel(): string
{
return '文章';
}
public static function getPluralModelLabel(): string
{
return '文章';
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
Group::make()
->schema([
Section::make('文章内容')
->schema([
TextInput::make('title')
->label('标题')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->label('URL 别名')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Textarea::make('excerpt')
->label('摘要')
->rows(3)
->maxLength(500)
->helperText('文章简短描述,用于列表展示'),
RichEditor::make('content')
->label('正文')
->required()
->columnSpanFull()
->fileAttachmentsDisk('public')
->fileAttachmentsDirectory('posts'),
])
->columns(2),
Section::make('图片')
->schema([
FileUpload::make('cover_image')
->label('封面图片')
->image()
->directory('posts/covers')
->imageEditor()
->imageEditorAspectRatios([
'16:9',
'4:3',
'1:1',
]),
FileUpload::make('gallery')
->label('图片墙')
->image()
->multiple()
->reorderable()
->directory('posts/gallery')
->maxFiles(9)
->helperText('最多上传 9 张图片ins 风格展示'),
])
->columns(2),
])
->columnSpan(['lg' => 2]),
Group::make()
->schema([
Section::make('发布设置')
->schema([
Select::make('status')
->label('状态')
->options([
'draft' => '草稿',
'published' => '已发布',
'scheduled' => '定时发布',
])
->default('draft')
->required()
->live(),
DateTimePicker::make('published_at')
->label('发布时间')
->default(now())
->visible(fn (callable $get) => in_array($get('status'), ['published', 'scheduled'])),
Toggle::make('is_featured')
->label('精选文章')
->helperText('精选文章将在首页突出显示'),
]),
Section::make('分类与标签')
->schema([
Select::make('category_id')
->label('分类')
->relationship('category', 'name')
->searchable()
->preload()
->createOptionForm([
TextInput::make('name')
->label('分类名称')
->required(),
TextInput::make('slug')
->label('别名')
->required(),
]),
Select::make('tags')
->label('标签')
->relationship('tags', 'name')
->multiple()
->searchable()
->preload()
->createOptionForm([
TextInput::make('name')
->label('标签名称')
->required(),
TextInput::make('slug')
->label('别名')
->required(),
ColorPicker::make('color')
->label('颜色')
->default('#6366f1'),
]),
]),
Section::make('统计信息')
->schema([
Placeholder::make('views_count')
->label('浏览量')
->content(fn (?Post $record): string => $record?->views_count ?? '0'),
Placeholder::make('likes_count')
->label('点赞数')
->content(fn (?Post $record): string => $record?->likes_count ?? '0'),
Placeholder::make('created_at')
->label('创建时间')
->content(fn (?Post $record): string => $record?->created_at?->format('Y-m-d H:i:s') ?? '-'),
Placeholder::make('updated_at')
->label('更新时间')
->content(fn (?Post $record): string => $record?->updated_at?->format('Y-m-d H:i:s') ?? '-'),
])
->hidden(fn (?Post $record) => $record === null),
])
->columnSpan(['lg' => 1]),
])
->columns(3);
}
public static function table(Table $table): Table
{
return $table
->columns([
ImageColumn::make('cover_image')
->label('封面')
->square(),
TextColumn::make('title')
->label('标题')
->searchable()
->sortable()
->limit(40),
TextColumn::make('category.name')
->label('分类')
->badge()
->sortable(),
TextColumn::make('status')
->label('状态')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'published' => 'success',
'scheduled' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'draft' => '草稿',
'published' => '已发布',
'scheduled' => '定时发布',
default => $state,
}),
IconColumn::make('is_featured')
->label('精选')
->boolean(),
TextColumn::make('views_count')
->label('浏览')
->numeric()
->sortable(),
TextColumn::make('published_at')
->label('发布时间')
->dateTime('Y-m-d H:i')
->sortable(),
TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('status')
->label('状态')
->options([
'draft' => '草稿',
'published' => '已发布',
'scheduled' => '定时发布',
]),
SelectFilter::make('category')
->label('分类')
->relationship('category', 'name'),
TernaryFilter::make('is_featured')
->label('精选'),
])
->actions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
\Filament\Actions\BulkActionGroup::make([
\Filament\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPosts::route('/'),
'create' => Pages\CreatePost::route('/create'),
'view' => Pages\ViewPost::route('/{record}'),
'edit' => Pages\EditPost::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PostResource\Pages;
use App\Filament\Resources\PostResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePost extends CreateRecord
{
protected static string $resource = PostResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['user_id'] = auth()->id();
return $data;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\PostResource\Pages;
use App\Filament\Resources\PostResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPost extends EditRecord
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PostResource\Pages;
use App\Filament\Resources\PostResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPosts extends ListRecords
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PostResource\Pages;
use App\Filament\Resources\PostResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewPost extends ViewRecord
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TagResource\Pages;
use App\Models\Tag;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\ColorColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class TagResource extends Resource
{
protected static ?string $model = Tag::class;
public static function getNavigationIcon(): string|\BackedEnum|null
{
return 'heroicon-o-tag';
}
public static function getNavigationSort(): ?int
{
return 2;
}
public static function getNavigationGroup(): ?string
{
return '内容管理';
}
public static function getModelLabel(): string
{
return '标签';
}
public static function getPluralModelLabel(): string
{
return '标签';
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('标签信息')
->schema([
TextInput::make('name')
->label('标签名称')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->label('URL 别名')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
ColorPicker::make('color')
->label('标签颜色')
->default('#6366f1'),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
ColorColumn::make('color')
->label('颜色'),
TextColumn::make('name')
->label('名称')
->searchable()
->sortable(),
TextColumn::make('slug')
->label('别名')
->searchable(),
TextColumn::make('posts_count')
->label('文章数')
->counts('posts')
->sortable(),
TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('name')
->filters([
//
])
->actions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
\Filament\Actions\BulkActionGroup::make([
\Filament\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTags::route('/'),
'create' => Pages\CreateTag::route('/create'),
'edit' => Pages\EditTag::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTag extends CreateRecord
{
protected static string $resource = TagResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTag extends EditRecord
{
protected static string $resource = TagResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTags extends ListRecords
{
protected static string $resource = TagResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BlogController extends Controller
{
/**
* 博客首页
*/
public function index(Request $request): View
{
$featuredPosts = Post::published()
->featured()
->with(['category', 'user', 'tags'])
->latest('published_at')
->take(3)
->get();
$posts = Post::published()
->with(['category', 'user', 'tags'])
->latest('published_at')
->paginate(12);
$categories = Category::active()
->ordered()
->withCount('publishedPosts')
->get();
return view('blog.index', compact('featuredPosts', 'posts', 'categories'));
}
/**
* 文章详情
*/
public function show(Post $post): View
{
// 只显示已发布的文章
abort_unless($post->isPublished(), 404);
// 增加浏览量
$post->incrementViews();
// 加载关联
$post->load(['category', 'user', 'tags']);
// 获取相关文章
$relatedPosts = Post::published()
->where('id', '!=', $post->id)
->where(function ($query) use ($post) {
$query->where('category_id', $post->category_id)
->orWhereHas('tags', function ($q) use ($post) {
$q->whereIn('tags.id', $post->tags->pluck('id'));
});
})
->with(['category', 'user'])
->latest('published_at')
->take(4)
->get();
return view('blog.show', compact('post', 'relatedPosts'));
}
/**
* 分类文章列表
*/
public function category(Category $category): View
{
abort_unless($category->is_active, 404);
$posts = $category->publishedPosts()
->with(['user', 'tags'])
->latest('published_at')
->paginate(12);
$categories = Category::active()
->ordered()
->withCount('publishedPosts')
->get();
return view('blog.category', compact('category', 'posts', 'categories'));
}
/**
* 标签文章列表
*/
public function tag(Tag $tag): View
{
$posts = $tag->publishedPosts()
->with(['category', 'user'])
->latest('published_at')
->paginate(12);
$categories = Category::active()
->ordered()
->withCount('publishedPosts')
->get();
return view('blog.tag', compact('tag', 'posts', 'categories'));
}
/**
* 搜索
*/
public function search(Request $request): View
{
$query = $request->input('q', '');
$posts = Post::published()
->where(function ($q) use ($query) {
$q->where('title', 'like', "%{$query}%")
->orWhere('excerpt', 'like', "%{$query}%")
->orWhere('content', 'like', "%{$query}%");
})
->with(['category', 'user', 'tags'])
->latest('published_at')
->paginate(12);
$categories = Category::active()
->ordered()
->withCount('publishedPosts')
->get();
return view('blog.search', compact('posts', 'query', 'categories'));
}
}

60
app/Models/Category.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'cover_image',
'sort_order',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
protected static function boot()
{
parent::boot();
static::creating(function ($category) {
if (empty($category->slug)) {
$category->slug = Str::slug($category->name);
}
});
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function publishedPosts(): HasMany
{
return $this->hasMany(Post::class)
->where('status', 'published')
->where('published_at', '<=', now());
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('name');
}
}

111
app/Models/Post.php Normal file
View File

@ -0,0 +1,111 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
'excerpt',
'content',
'cover_image',
'gallery',
'status',
'published_at',
'is_featured',
'views_count',
'likes_count',
];
protected $casts = [
'gallery' => 'array',
'published_at' => 'datetime',
'is_featured' => 'boolean',
];
protected static function boot()
{
parent::boot();
static::creating(function ($post) {
if (empty($post->slug)) {
$post->slug = Str::slug($post->title);
}
if (empty($post->user_id)) {
$post->user_id = auth()->id();
}
});
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
public function scopePublished($query)
{
return $query->where('status', 'published')
->where('published_at', '<=', now());
}
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
public function scopeLatest($query)
{
return $query->orderBy('published_at', 'desc');
}
public function isPublished(): bool
{
return $this->status === 'published' && $this->published_at <= now();
}
public function getReadingTimeAttribute(): int
{
$wordCount = str_word_count(strip_tags($this->content));
return max(1, ceil($wordCount / 200));
}
public function incrementViews(): void
{
$this->increment('views_count');
}
public function getFirstGalleryImageAttribute(): ?string
{
if (!empty($this->gallery) && is_array($this->gallery)) {
return $this->gallery[0] ?? null;
}
return null;
}
public function getDisplayImageAttribute(): ?string
{
return $this->cover_image ?? $this->first_gallery_image;
}
}

44
app/Models/Tag.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
class Tag extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'color',
];
protected static function boot()
{
parent::boot();
static::creating(function ($tag) {
if (empty($tag->slug)) {
$tag->slug = Str::slug($tag->name);
}
});
}
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class)->withTimestamps();
}
public function publishedPosts(): BelongsToMany
{
return $this->belongsToMany(Post::class)
->withTimestamps()
->where('status', 'published')
->where('published_at', '<=', now());
}
}

View File

@ -3,11 +3,14 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
@ -45,4 +48,14 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function canAccessPanel(Panel $panel): bool
{
return true;
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}

199
config/livewire.php Normal file
View File

@ -0,0 +1,199 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component
| as an entire page via `Route::get('/post/create', CreatePost::class);`.
| In this case, the view returned by CreatePost will render into $slot.
|
*/
'layout' => 'components.layouts.app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'lazy_placeholder' => null,
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Asset URL
|---------------------------------------------------------------------------
|
| This value sets the path to Livewire JavaScript assets, for CDN or local
| assets. Setting it to `null` or removing the configuration will let
| Livewire automatically generate asset URL based on app URL.
|
*/
'asset_url' => null,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => false,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
];

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Category>
*/
class CategoryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->randomElement([
'生活随笔', '旅行日记', '美食探店', '穿搭分享',
'居家装饰', '读书笔记', '摄影作品', '数码科技',
'健身运动', '心情日志', '工作感悟', '艺术欣赏'
]);
return [
'name' => $name,
'slug' => Str::slug($name) . '-' . fake()->unique()->randomNumber(4),
'description' => fake()->sentence(10),
'sort_order' => fake()->numberBetween(0, 10),
'is_active' => true,
];
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Post>
*/
class PostFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$titles = [
'周末的咖啡时光,享受慢生活的美好',
'探索城市隐藏的宝藏小店',
'今日穿搭分享|简约不简单',
'一个人的旅行,遇见不一样的风景',
'家居改造计划|打造温馨小窝',
'读完这本书,我的思考',
'春日踏青,感受大自然的馈赠',
'美食日记|自制下午茶',
'工作之余的小确幸',
'艺术展观后感|美的感悟',
'健身打卡第30天变化看得见',
'治愈系好物分享',
'摄影技巧|如何拍出氛围感',
'极简生活|断舍离的心得',
'复古风穿搭灵感',
'居家办公的一天',
'周末brunch推荐',
'秋日漫步|落叶缤纷',
'手账分享|记录生活的美好',
'护肤心得|找到适合自己的',
];
$title = fake()->randomElement($titles) . ' ' . fake()->emoji();
$content = '<p>' . fake()->paragraphs(3, true) . '</p>';
$content .= '<h2>关于这次体验</h2>';
$content .= '<p>' . fake()->paragraphs(2, true) . '</p>';
$content .= '<blockquote><p>' . fake()->sentence(15) . '</p></blockquote>';
$content .= '<p>' . fake()->paragraphs(2, true) . '</p>';
$content .= '<h3>一些小建议</h3>';
$content .= '<ul><li>' . fake()->sentence() . '</li>';
$content .= '<li>' . fake()->sentence() . '</li>';
$content .= '<li>' . fake()->sentence() . '</li></ul>';
$content .= '<p>' . fake()->paragraphs(2, true) . '</p>';
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'title' => $title,
'slug' => Str::slug($title) . '-' . fake()->unique()->randomNumber(5),
'excerpt' => fake()->sentence(20),
'content' => $content,
'cover_image' => null,
'gallery' => null,
'status' => fake()->randomElement(['draft', 'published', 'published', 'published']),
'published_at' => fake()->dateTimeBetween('-6 months', 'now'),
'is_featured' => fake()->boolean(20),
'views_count' => fake()->numberBetween(0, 5000),
'likes_count' => fake()->numberBetween(0, 500),
];
}
/**
* Indicate that the post is published.
*/
public function published(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'published',
'published_at' => fake()->dateTimeBetween('-6 months', 'now'),
]);
}
/**
* Indicate that the post is featured.
*/
public function featured(): static
{
return $this->state(fn (array $attributes) => [
'is_featured' => true,
]);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Tag>
*/
class TagFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->randomElement([
'日常', '打卡', '分享', '推荐', '种草',
'好物', '记录', '灵感', '创意', '治愈',
'氛围感', '高级感', '极简', '复古', '小众',
'宝藏', '私藏', '必看', '干货', '教程'
]);
$colors = [
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
'#22c55e', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6',
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
'#f43f5e'
];
return [
'name' => $name,
'slug' => Str::slug($name) . '-' . fake()->unique()->randomNumber(4),
'color' => fake()->randomElement($colors),
];
}
}

View File

@ -0,0 +1,33 @@
<?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('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->string('cover_image')->nullable();
$table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categories');
}
};

View File

@ -0,0 +1,31 @@
<?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('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('color')->default('#6366f1');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tags');
}
};

View File

@ -0,0 +1,55 @@
<?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('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt')->nullable();
$table->longText('content');
$table->string('cover_image')->nullable();
$table->json('gallery')->nullable(); // ins风格的图片墙
$table->enum('status', ['draft', 'published', 'scheduled'])->default('draft');
$table->timestamp('published_at')->nullable();
$table->boolean('is_featured')->default(false);
$table->unsignedInteger('views_count')->default(0);
$table->unsignedInteger('likes_count')->default(0);
$table->timestamps();
$table->index(['status', 'published_at']);
$table->index('is_featured');
});
// 文章标签多对多关联表
Schema::create('post_tag', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['post_id', 'tag_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('post_tag');
Schema::dropIfExists('posts');
}
};

View File

@ -0,0 +1,144 @@
<?php
namespace Database\Seeders;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class BlogSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// 创建管理员用户
$admin = User::firstOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => Hash::make('password'),
]
);
// 创建分类
$categories = [
['name' => '生活随笔', 'slug' => 'life', 'description' => '记录生活中的点滴感悟', 'sort_order' => 1],
['name' => '旅行日记', 'slug' => 'travel', 'description' => '探索世界的每一个角落', 'sort_order' => 2],
['name' => '美食探店', 'slug' => 'food', 'description' => '寻找城市中的美味', 'sort_order' => 3],
['name' => '穿搭分享', 'slug' => 'fashion', 'description' => '日常穿搭灵感', 'sort_order' => 4],
['name' => '摄影作品', 'slug' => 'photography', 'description' => '用镜头记录美好', 'sort_order' => 5],
['name' => '读书笔记', 'slug' => 'reading', 'description' => '阅读与思考', 'sort_order' => 6],
];
foreach ($categories as $categoryData) {
Category::firstOrCreate(['slug' => $categoryData['slug']], $categoryData);
}
// 创建标签
$tags = [
['name' => '日常', 'slug' => 'daily', 'color' => '#6366f1'],
['name' => '推荐', 'slug' => 'recommend', 'color' => '#ec4899'],
['name' => '种草', 'slug' => 'wishlist', 'color' => '#22c55e'],
['name' => '打卡', 'slug' => 'check-in', 'color' => '#f59e0b'],
['name' => '治愈', 'slug' => 'healing', 'color' => '#14b8a6'],
['name' => '灵感', 'slug' => 'inspiration', 'color' => '#8b5cf6'],
['name' => '极简', 'slug' => 'minimal', 'color' => '#64748b'],
['name' => '氛围感', 'slug' => 'aesthetic', 'color' => '#d946ef'],
['name' => '干货', 'slug' => 'tips', 'color' => '#0ea5e9'],
['name' => '宝藏', 'slug' => 'treasure', 'color' => '#f43f5e'],
];
foreach ($tags as $tagData) {
Tag::firstOrCreate(['slug' => $tagData['slug']], $tagData);
}
$allCategories = Category::all();
$allTags = Tag::all();
// 创建示例文章
$posts = [
[
'title' => '周末的咖啡时光 ☕',
'slug' => 'weekend-coffee-time',
'excerpt' => '在繁忙的生活中,给自己一个慢下来的理由。一杯咖啡,一本书,享受属于自己的周末时光。',
'content' => '<p>周末的早晨,阳光透过窗帘洒进房间,我喜欢这样慵懒地醒来。</p><p>不急不慢地准备一杯手冲咖啡,看着咖啡粉在热水中绽放,香气渐渐弥漫整个房间。这大概就是生活中最简单的幸福吧。</p><h2>关于这家咖啡店</h2><p>最近发现了一家藏在巷子里的小店,装修简约却很有格调。老板是个很有趣的人,每次去都会聊上几句。</p><blockquote><p>「生活不止眼前的苟且,还有诗和远方。」</p></blockquote><p>希望每个人都能找到属于自己的小确幸。</p>',
'is_featured' => true,
],
[
'title' => '探索城市的隐藏角落 🏙️',
'slug' => 'explore-hidden-city',
'excerpt' => '在熟悉的城市中,总有一些角落等待被发现。这次我用一天的时间,探索了几个从未去过的地方。',
'content' => '<p>生活在一个城市久了,总觉得已经把每个角落都走遍了。但其实,只要换一个视角,就能发现很多之前忽略的美好。</p><h2>老城区的午后</h2><p>老城区的街道总有一种特别的气息,斑驳的墙面诉说着岁月的故事。</p><p>偶遇一家开了30年的老店老板娘热情地介绍着店里的招牌。原来最美的风景一直就在身边。</p><h3>推荐路线</h3><ul><li>早上:从老城区的早餐店开始</li><li>中午:漫步胡同,感受生活气息</li><li>下午:找一家安静的咖啡馆坐坐</li></ul>',
'is_featured' => true,
],
[
'title' => '今日穿搭分享 | 极简风 👗',
'slug' => 'outfit-minimal-style',
'excerpt' => '分享一套日常通勤穿搭舒适又不失精致。极简主义的魅力在于less is more。',
'content' => '<p>今天分享一套我最近很喜欢的穿搭,适合日常通勤,也适合周末约会。</p><h2>单品分享</h2><p>上衣选择了一件基础款的白色T恤百搭又显气质。下装是一条高腰阔腿裤拉长腿部线条。</p><p>配饰上我选择了简约的金色项链和耳环,点缀整体造型。</p><blockquote><p>「穿衣服要舒服,更要有自己的风格。」</p></blockquote><h3>搭配tips</h3><ul><li>基础款单品要注重质感</li><li>配色尽量控制在三种以内</li><li>配饰是点睛之笔</li></ul>',
'is_featured' => true,
],
[
'title' => '居家改造计划 | 打造温馨小窝 🏠',
'slug' => 'home-renovation-plan',
'excerpt' => '一直想改造一下自己的房间,终于动手了!分享一些居家改造的心得。',
'content' => '<p>租房多年,一直觉得家只是一个睡觉的地方。但最近开始意识到,家应该是一个让人放松的空间。</p><h2>改造思路</h2><p>首先是整理收纳,把不需要的东西都断舍离。然后添置了一些温馨的装饰品,比如香薰蜡烛、绿植、温暖的灯光。</p><p>改造后的房间真的让人心情好很多!</p><h3>好物推荐</h3><ul><li>无火香薰:提升空间氛围</li><li>暖光台灯:营造温馨感</li><li>收纳盒:保持整洁</li></ul>',
'is_featured' => false,
],
[
'title' => '读书笔记 | 《被讨厌的勇气》📚',
'slug' => 'reading-notes-courage',
'excerpt' => '最近读完了这本书,对阿德勒心理学有了新的认识。分享一些读后感。',
'content' => '<p>这本书用对话的形式,讲述了阿德勒心理学的核心观点。读完后,对很多事情有了新的看法。</p><h2>核心观点</h2><p>书中提到,我们的人生是由自己选择的,不应该被过去所束缚。每个人都有改变的能力。</p><blockquote><p>「人生没有意义,生命的意义是你自己赋予的。」</p></blockquote><h3>读后感</h3><p>读完这本书,最大的收获是学会了「课题分离」。每个人都应该专注于自己的课题,不要过多干涉别人的课题。</p>',
'is_featured' => false,
],
[
'title' => '美食日记 | 自制抹茶提拉米苏 🍰',
'slug' => 'food-matcha-tiramisu',
'excerpt' => '周末尝试做了抹茶提拉米苏,没想到意外的成功!分享食谱和制作过程。',
'content' => '<p>一直很喜欢抹茶口味的甜品,这次尝试自己动手做提拉米苏,效果超出预期!</p><h2>食材准备</h2><p>马斯卡彭芝士、抹茶粉、手指饼干、鲜奶油、蛋黄、糖粉等。</p><h2>制作步骤</h2><p>1. 蛋黄加糖隔水加热打发<br>2. 马斯卡彭芝士打软后混合蛋黄糊<br>3. 手指饼干蘸抹茶液铺底<br>4. 铺上芝士糊,重复几层<br>5. 冷藏4小时以上</p><p>自己做的甜品就是不一样,满满的成就感!</p>',
'is_featured' => false,
],
[
'title' => '春日踏青 | 感受大自然的美好 🌸',
'slug' => 'spring-outing-nature',
'excerpt' => '趁着春光正好,来一场说走就走的踏青之旅。花海、蓝天、微风,一切都刚刚好。',
'content' => '<p>等了一个冬天,终于迎来了春暖花开的季节。约上三五好友,去郊外感受大自然的魅力。</p><h2>目的地</h2><p>这次选择了城郊的一个花海公园,樱花和油菜花开得正盛。</p><p>阳光洒在身上,暖暖的,让人忍不住放慢脚步,好好感受这一刻的美好。</p><blockquote><p>「春天就是要出去走走,感受生命的力量。」</p></blockquote><h3>拍照tips</h3><ul><li>选择早上或傍晚的光线</li><li>穿浅色衣服更出片</li><li>善用前景构图</li></ul>',
'is_featured' => false,
],
[
'title' => '健身打卡 | 坚持的力量 💪',
'slug' => 'fitness-check-in',
'excerpt' => '健身一个月的变化分享。虽然过程很辛苦,但看到自己的进步,一切都值得。',
'content' => '<p>一个月前开始认真健身,从最开始的不适应到现在慢慢形成习惯,收获了很多。</p><h2>我的训练计划</h2><p>每周训练4-5次有氧和力量交替进行。刚开始的时候真的很累但坚持下来后身体素质明显提高了。</p><h3>一些建议</h3><ul><li>制定合理的计划,循序渐进</li><li>饮食要配合,保证蛋白质摄入</li><li>休息同样重要,不要过度训练</li><li>找到自己喜欢的运动方式</li></ul><p>希望这篇分享能给想开始健身的朋友一些动力!</p>',
'is_featured' => false,
],
];
foreach ($posts as $index => $postData) {
$post = Post::firstOrCreate(
['slug' => $postData['slug']],
array_merge($postData, [
'user_id' => $admin->id,
'category_id' => $allCategories->random()->id,
'status' => 'published',
'published_at' => now()->subDays(rand(1, 30)),
'views_count' => rand(100, 3000),
'likes_count' => rand(10, 300),
])
);
// 随机关联2-4个标签
$post->tags()->sync($allTags->random(rand(2, 4))->pluck('id'));
}
$this->command->info('Blog seeder completed successfully!');
$this->command->info('Admin login: admin@example.com / password');
}
}

View File

@ -2,24 +2,17 @@
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$this->call([
BlogSeeder::class,
]);
}
}

2427
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0",
"concurrently": "^9.0.1",

11355
public/vendor/livewire/livewire.esm.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

10468
public/vendor/livewire/livewire.js vendored Normal file

File diff suppressed because it is too large Load Diff

98
public/vendor/livewire/livewire.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
public/vendor/livewire/manifest.json vendored Normal file
View File

@ -0,0 +1,2 @@
{"/livewire.js":"0f6341c0"}

View File

@ -1,4 +1,5 @@
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@ -6,6 +7,80 @@
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: 'Playfair Display', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f5f5f4;
}
::-webkit-scrollbar-thumb {
background: #d6d3d1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a29e;
}
/* Line clamp utilities */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Smooth page transitions */
html {
scroll-behavior: smooth;
}
/* Image aspect ratio fix */
img {
max-width: 100%;
height: auto;
}
/* Pagination styling */
nav[role="navigation"] {
display: flex;
justify-content: center;
}
nav[role="navigation"] .flex {
gap: 0.5rem;
}
nav[role="navigation"] a,
nav[role="navigation"] span {
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
nav[role="navigation"] a:hover {
background-color: #f5f5f4;
}
nav[role="navigation"] span[aria-current="page"] {
background-color: #1c1917;
color: white;
}

View File

@ -0,0 +1,67 @@
<x-layout :title="$category->name . ' - Muse'">
<!-- Category Header -->
<section class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-rose-50 via-fuchsia-50/50 to-indigo-50"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-20">
<div class="text-center max-w-2xl mx-auto">
<span class="inline-block px-4 py-1.5 text-sm font-medium bg-white/80 backdrop-blur-sm text-fuchsia-600 rounded-full mb-4 shadow-sm">
分类
</span>
<h1 class="font-serif text-3xl sm:text-4xl lg:text-5xl font-bold text-stone-900 mb-4">
{{ $category->name }}
</h1>
@if($category->description)
<p class="text-lg text-stone-600">{{ $category->description }}</p>
@endif
<p class="mt-4 text-stone-500"> {{ $posts->total() }} 篇文章</p>
</div>
</div>
</section>
<!-- Categories Pills -->
@if($categories->isNotEmpty())
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="{{ route('home') }}"
class="px-5 py-2.5 text-sm font-medium rounded-full bg-white text-stone-700 hover:bg-stone-100 shadow-sm transition-all duration-300">
全部
</a>
@foreach($categories as $cat)
<a href="{{ route('category', $cat) }}"
class="px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-300
{{ $cat->id === $category->id ? 'bg-stone-900 text-white shadow-lg shadow-stone-900/25' : 'bg-white text-stone-700 hover:bg-stone-100 shadow-sm' }}">
{{ $cat->name }}
</a>
@endforeach
</div>
</section>
@endif
<!-- Posts Grid -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
@if($posts->isNotEmpty())
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 lg:gap-8">
@foreach($posts as $post)
<x-post-card :post="$post" />
@endforeach
</div>
<!-- Pagination -->
<div class="mt-12">
{{ $posts->links() }}
</div>
@else
<div class="text-center py-20">
<div class="w-20 h-20 mx-auto mb-6 bg-stone-100 rounded-full flex items-center justify-center">
<svg class="w-10 h-10 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
<h3 class="text-xl font-medium text-stone-900 mb-2">该分类暂无文章</h3>
<p class="text-stone-500">敬请期待更多精彩内容</p>
</div>
@endif
</section>
</x-layout>

View File

@ -0,0 +1,82 @@
<x-layout title="Muse - 记录生活的美好">
<!-- Hero Section -->
<section class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-rose-50 via-fuchsia-50/50 to-indigo-50"></div>
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 20% 50%, rgba(236, 72, 153, 0.08) 0%, transparent 50%), radial-gradient(circle at 80% 50%, rgba(99, 102, 241, 0.08) 0%, transparent 50%);"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
<div class="text-center max-w-3xl mx-auto">
<h1 class="font-serif text-4xl sm:text-5xl lg:text-6xl font-bold text-stone-900 mb-6">
<span class="bg-gradient-to-r from-rose-600 via-fuchsia-600 to-indigo-600 bg-clip-text text-transparent">探索</span>
生活的诗意
</h1>
<p class="text-lg sm:text-xl text-stone-600 leading-relaxed">
每一个瞬间都值得被记录,每一段故事都值得被聆听。<br class="hidden sm:block">
在这里,发现属于你的灵感与共鸣。
</p>
</div>
</div>
</section>
<!-- Featured Posts -->
@if($featuredPosts->isNotEmpty())
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($featuredPosts as $post)
<x-post-card :post="$post" :featured="true" />
@endforeach
</div>
</section>
@endif
<!-- Categories Pills -->
@if($categories->isNotEmpty())
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="{{ route('home') }}"
class="px-5 py-2.5 text-sm font-medium rounded-full transition-all duration-300
{{ !request()->routeIs('category') ? 'bg-stone-900 text-white shadow-lg shadow-stone-900/25' : 'bg-white text-stone-700 hover:bg-stone-100 shadow-sm' }}">
全部
</a>
@foreach($categories as $cat)
<a href="{{ route('category', $cat) }}"
class="px-5 py-2.5 text-sm font-medium rounded-full bg-white text-stone-700 hover:bg-stone-100 shadow-sm transition-all duration-300">
{{ $cat->name }}
<span class="ml-1 text-stone-400">({{ $cat->published_posts_count }})</span>
</a>
@endforeach
</div>
</section>
@endif
<!-- Posts Grid -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
<div class="flex items-center justify-between mb-8">
<h2 class="font-serif text-2xl sm:text-3xl font-semibold text-stone-900">最新文章</h2>
</div>
@if($posts->isNotEmpty())
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 lg:gap-8">
@foreach($posts as $post)
<x-post-card :post="$post" />
@endforeach
</div>
<!-- Pagination -->
<div class="mt-12">
{{ $posts->links() }}
</div>
@else
<div class="text-center py-20">
<div class="w-20 h-20 mx-auto mb-6 bg-stone-100 rounded-full flex items-center justify-center">
<svg class="w-10 h-10 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
<h3 class="text-xl font-medium text-stone-900 mb-2">暂无文章</h3>
<p class="text-stone-500">敬请期待更多精彩内容</p>
</div>
@endif
</section>
</x-layout>

View File

@ -0,0 +1,83 @@
<x-layout :title="'搜索: ' . $query . ' - Muse'">
<!-- Search Header -->
<section class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-rose-50 via-fuchsia-50/50 to-indigo-50"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-20">
<div class="text-center max-w-2xl mx-auto">
<div class="inline-flex items-center justify-center w-16 h-16 bg-stone-100 rounded-full mb-6">
<svg class="w-8 h-8 text-stone-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
@if($query)
<h1 class="font-serif text-3xl sm:text-4xl font-bold text-stone-900 mb-4">
搜索结果: "{{ $query }}"
</h1>
<p class="text-stone-500">找到 {{ $posts->total() }} 篇相关文章</p>
@else
<h1 class="font-serif text-3xl sm:text-4xl font-bold text-stone-900 mb-4">
搜索文章
</h1>
@endif
<!-- Search Form -->
<form action="{{ route('search') }}" method="GET" class="mt-8 max-w-md mx-auto">
<div class="relative">
<input type="text" name="q" value="{{ $query }}" placeholder="输入关键词搜索..."
class="w-full pl-12 pr-4 py-4 text-lg bg-white border-0 rounded-2xl shadow-lg focus:ring-2 focus:ring-fuchsia-500/50 transition-all">
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</form>
</div>
</div>
</section>
<!-- Categories Pills -->
@if($categories->isNotEmpty())
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="{{ route('home') }}"
class="px-5 py-2.5 text-sm font-medium rounded-full bg-white text-stone-700 hover:bg-stone-100 shadow-sm transition-all duration-300">
全部
</a>
@foreach($categories as $cat)
<a href="{{ route('category', $cat) }}"
class="px-5 py-2.5 text-sm font-medium rounded-full bg-white text-stone-700 hover:bg-stone-100 shadow-sm transition-all duration-300">
{{ $cat->name }}
</a>
@endforeach
</div>
</section>
@endif
<!-- Posts Grid -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
@if($posts->isNotEmpty())
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 lg:gap-8">
@foreach($posts as $post)
<x-post-card :post="$post" />
@endforeach
</div>
<!-- Pagination -->
<div class="mt-12">
{{ $posts->withQueryString()->links() }}
</div>
@elseif($query)
<div class="text-center py-20">
<div class="w-20 h-20 mx-auto mb-6 bg-stone-100 rounded-full flex items-center justify-center">
<svg class="w-10 h-10 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-xl font-medium text-stone-900 mb-2">未找到相关文章</h3>
<p class="text-stone-500">尝试使用其他关键词搜索</p>
</div>
@endif
</section>
</x-layout>

View File

@ -0,0 +1,177 @@
<x-layout :title="$post->title . ' - Muse'">
<article class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-16">
<!-- Header -->
<header class="text-center mb-10 lg:mb-16">
@if($post->category)
<a href="{{ route('category', $post->category) }}"
class="inline-block px-4 py-1.5 text-sm font-medium bg-gradient-to-r from-rose-100 to-fuchsia-100 text-fuchsia-700 rounded-full mb-6 hover:from-rose-200 hover:to-fuchsia-200 transition-colors">
{{ $post->category->name }}
</a>
@endif
<h1 class="font-serif text-3xl sm:text-4xl lg:text-5xl font-bold text-stone-900 leading-tight mb-6">
{{ $post->title }}
</h1>
@if($post->excerpt)
<p class="text-lg sm:text-xl text-stone-600 max-w-2xl mx-auto leading-relaxed">
{{ $post->excerpt }}
</p>
@endif
<div class="flex flex-wrap items-center justify-center gap-4 mt-8 text-sm text-stone-500">
<div class="flex items-center">
<div class="w-10 h-10 bg-gradient-to-br from-rose-400 to-fuchsia-500 rounded-full flex items-center justify-center text-white font-medium mr-3">
{{ substr($post->user->name ?? 'U', 0, 1) }}
</div>
<span class="font-medium text-stone-700">{{ $post->user->name ?? '匿名作者' }}</span>
</div>
<span class="hidden sm:inline">·</span>
<span>{{ $post->published_at?->format('Y年m月d日') }}</span>
<span class="hidden sm:inline">·</span>
<span>{{ $post->reading_time }} 分钟阅读</span>
<span class="hidden sm:inline">·</span>
<span>{{ number_format($post->views_count) }} 次阅读</span>
</div>
</header>
<!-- Cover Image -->
@if($post->cover_image)
<div class="mb-10 lg:mb-16 -mx-4 sm:mx-0">
<img src="{{ Storage::url($post->cover_image) }}"
alt="{{ $post->title }}"
class="w-full aspect-[16/9] object-cover sm:rounded-2xl lg:rounded-3xl">
</div>
@endif
<!-- Gallery (Instagram Style) -->
@if($post->gallery && count($post->gallery) > 0)
<div class="mb-10 lg:mb-16">
@php
$galleryCount = count($post->gallery);
@endphp
@if($galleryCount == 1)
<div class="aspect-square rounded-2xl overflow-hidden">
<img src="{{ Storage::url($post->gallery[0]) }}" alt="" class="w-full h-full object-cover">
</div>
@elseif($galleryCount == 2)
<div class="grid grid-cols-2 gap-2 rounded-2xl overflow-hidden">
@foreach($post->gallery as $image)
<div class="aspect-square">
<img src="{{ Storage::url($image) }}" alt="" class="w-full h-full object-cover">
</div>
@endforeach
</div>
@elseif($galleryCount == 3)
<div class="grid grid-cols-3 gap-2 rounded-2xl overflow-hidden">
@foreach($post->gallery as $image)
<div class="aspect-square">
<img src="{{ Storage::url($image) }}" alt="" class="w-full h-full object-cover">
</div>
@endforeach
</div>
@elseif($galleryCount == 4)
<div class="grid grid-cols-2 gap-2 rounded-2xl overflow-hidden">
@foreach($post->gallery as $image)
<div class="aspect-square">
<img src="{{ Storage::url($image) }}" alt="" class="w-full h-full object-cover">
</div>
@endforeach
</div>
@else
<div class="grid grid-cols-3 gap-2 rounded-2xl overflow-hidden">
@foreach(array_slice($post->gallery, 0, 9) as $index => $image)
<div class="aspect-square relative">
<img src="{{ Storage::url($image) }}" alt="" class="w-full h-full object-cover">
@if($index == 8 && $galleryCount > 9)
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
<span class="text-white text-2xl font-semibold">+{{ $galleryCount - 9 }}</span>
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
@endif
<!-- Content -->
<div class="prose prose-lg prose-stone max-w-none
prose-headings:font-serif prose-headings:font-semibold
prose-h2:text-2xl prose-h2:mt-12 prose-h2:mb-4
prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3
prose-p:text-stone-700 prose-p:leading-relaxed
prose-a:text-fuchsia-600 prose-a:no-underline hover:prose-a:underline
prose-img:rounded-xl prose-img:shadow-lg
prose-blockquote:border-l-fuchsia-400 prose-blockquote:bg-stone-50 prose-blockquote:py-1 prose-blockquote:rounded-r-lg
prose-code:text-fuchsia-600 prose-code:bg-stone-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-stone-900 prose-pre:rounded-xl">
{!! $post->content !!}
</div>
<!-- Tags -->
@if($post->tags->isNotEmpty())
<div class="mt-12 pt-8 border-t border-stone-200">
<div class="flex flex-wrap items-center gap-2">
<span class="text-stone-500 mr-2">标签:</span>
@foreach($post->tags as $tag)
<a href="{{ route('tag', $tag) }}"
class="px-4 py-1.5 text-sm font-medium rounded-full transition-colors"
style="background-color: {{ $tag->color }}20; color: {{ $tag->color }}">
#{{ $tag->name }}
</a>
@endforeach
</div>
</div>
@endif
<!-- Share & Actions -->
<div class="mt-8 pt-8 border-t border-stone-200">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<button class="flex items-center space-x-2 px-4 py-2 bg-stone-100 hover:bg-stone-200 rounded-full transition-colors">
<svg class="w-5 h-5 text-rose-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<span class="text-sm font-medium text-stone-700">{{ number_format($post->likes_count) }}</span>
</button>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm text-stone-500">分享:</span>
<a href="https://twitter.com/intent/tweet?url={{ urlencode(request()->url()) }}&text={{ urlencode($post->title) }}"
target="_blank"
class="p-2 bg-stone-100 hover:bg-stone-200 rounded-full transition-colors">
<svg class="w-5 h-5 text-stone-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode(request()->url()) }}"
target="_blank"
class="p-2 bg-stone-100 hover:bg-stone-200 rounded-full transition-colors">
<svg class="w-5 h-5 text-stone-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</a>
</div>
</div>
</div>
</article>
<!-- Related Posts -->
@if($relatedPosts->isNotEmpty())
<section class="bg-stone-100/50 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="font-serif text-2xl sm:text-3xl font-semibold text-stone-900 mb-8 text-center">相关文章</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
@foreach($relatedPosts as $relatedPost)
<x-post-card :post="$relatedPost" />
@endforeach
</div>
</div>
</section>
@endif
</x-layout>

View File

@ -0,0 +1,66 @@
<x-layout :title="'#' . $tag->name . ' - Muse'">
<!-- Tag Header -->
<section class="relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-rose-50 via-fuchsia-50/50 to-indigo-50"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-20">
<div class="text-center max-w-2xl mx-auto">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-6"
style="background-color: {{ $tag->color }}20">
<svg class="w-8 h-8" style="color: {{ $tag->color }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<h1 class="font-serif text-3xl sm:text-4xl lg:text-5xl font-bold text-stone-900 mb-4">
#{{ $tag->name }}
</h1>
<p class="text-stone-500"> {{ $posts->total() }} 篇文章</p>
</div>
</div>
</section>
<!-- Categories Pills -->
@if($categories->isNotEmpty())
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="{{ route('home') }}"
class="px-5 py-2.5 text-sm font-medium rounded-full bg-white text-stone-700 hover:bg-stone-100 shadow-sm transition-all duration-300">
全部
</a>
@foreach($categories as $cat)
<a href="{{ route('category', $cat) }}"
class="px-5 py-2.5 text-sm font-medium rounded-full bg-white text-stone-700 hover:bg-stone-100 shadow-sm transition-all duration-300">
{{ $cat->name }}
</a>
@endforeach
</div>
</section>
@endif
<!-- Posts Grid -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
@if($posts->isNotEmpty())
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 lg:gap-8">
@foreach($posts as $post)
<x-post-card :post="$post" />
@endforeach
</div>
<!-- Pagination -->
<div class="mt-12">
{{ $posts->links() }}
</div>
@else
<div class="text-center py-20">
<div class="w-20 h-20 mx-auto mb-6 bg-stone-100 rounded-full flex items-center justify-center">
<svg class="w-10 h-10 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
<h3 class="text-xl font-medium text-stone-900 mb-2">该标签暂无文章</h3>
<p class="text-stone-500">敬请期待更多精彩内容</p>
</div>
@endif
</section>
</x-layout>

View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? config('app.name', 'Blog') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=playfair-display:400,500,600,700|dm-sans:300,400,500,600,700&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="antialiased bg-stone-50 text-stone-900 min-h-screen">
<!-- Navigation -->
<nav class="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-xl border-b border-stone-200/50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16 lg:h-20">
<!-- Logo -->
<a href="{{ route('home') }}" class="flex items-center space-x-2">
<div class="w-10 h-10 bg-gradient-to-br from-rose-400 via-fuchsia-500 to-indigo-500 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<span class="font-serif text-xl lg:text-2xl font-semibold tracking-tight">Muse</span>
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8">
<a href="{{ route('home') }}" class="text-sm font-medium text-stone-600 hover:text-stone-900 transition-colors">首页</a>
@php
$navCategories = \App\Models\Category::active()->ordered()->take(4)->get();
@endphp
@foreach($navCategories as $cat)
<a href="{{ route('category', $cat) }}" class="text-sm font-medium text-stone-600 hover:text-stone-900 transition-colors">{{ $cat->name }}</a>
@endforeach
</div>
<!-- Search -->
<div class="flex items-center space-x-4">
<form action="{{ route('search') }}" method="GET" class="relative hidden sm:block">
<input type="text" name="q" placeholder="搜索文章..." value="{{ request('q') }}"
class="w-48 lg:w-64 pl-10 pr-4 py-2 text-sm bg-stone-100 border-0 rounded-full focus:ring-2 focus:ring-fuchsia-500/50 focus:bg-white transition-all">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</form>
<!-- Mobile Menu Button -->
<button type="button" class="md:hidden p-2 rounded-lg hover:bg-stone-100 transition-colors"
onclick="document.getElementById('mobile-menu').classList.toggle('hidden')">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden md:hidden border-t border-stone-200/50 bg-white">
<div class="px-4 py-4 space-y-3">
<form action="{{ route('search') }}" method="GET" class="relative sm:hidden">
<input type="text" name="q" placeholder="搜索文章..." value="{{ request('q') }}"
class="w-full pl-10 pr-4 py-2.5 text-sm bg-stone-100 border-0 rounded-full focus:ring-2 focus:ring-fuchsia-500/50">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</form>
<a href="{{ route('home') }}" class="block px-3 py-2 text-base font-medium text-stone-700 hover:bg-stone-50 rounded-lg">首页</a>
@foreach($navCategories as $cat)
<a href="{{ route('category', $cat) }}" class="block px-3 py-2 text-base font-medium text-stone-700 hover:bg-stone-50 rounded-lg">{{ $cat->name }}</a>
@endforeach
</div>
</div>
</nav>
<!-- Main Content -->
<main class="pt-16 lg:pt-20">
{{ $slot }}
</main>
<!-- Footer -->
<footer class="bg-stone-900 text-stone-300 mt-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div class="grid grid-cols-1 md:grid-cols-4 gap-12">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="{{ route('home') }}" class="flex items-center space-x-2 mb-4">
<div class="w-10 h-10 bg-gradient-to-br from-rose-400 via-fuchsia-500 to-indigo-500 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<span class="font-serif text-xl font-semibold text-white">Muse</span>
</a>
<p class="text-stone-400 max-w-md">
记录生活中的美好瞬间,分享独特的视角与思考。每一篇文章都是一次心灵的旅行。
</p>
</div>
<!-- Categories -->
<div>
<h4 class="text-white font-semibold mb-4">分类</h4>
<ul class="space-y-2">
@foreach($navCategories as $cat)
<li>
<a href="{{ route('category', $cat) }}" class="text-stone-400 hover:text-white transition-colors">{{ $cat->name }}</a>
</li>
@endforeach
</ul>
</div>
<!-- Links -->
<div>
<h4 class="text-white font-semibold mb-4">链接</h4>
<ul class="space-y-2">
<li><a href="{{ route('home') }}" class="text-stone-400 hover:text-white transition-colors">首页</a></li>
<li><a href="/admin" class="text-stone-400 hover:text-white transition-colors">管理后台</a></li>
</ul>
</div>
</div>
<div class="border-t border-stone-800 mt-12 pt-8 text-center text-stone-500 text-sm">
<p>&copy; {{ date('Y') }} Muse Blog. All rights reserved.</p>
</div>
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,90 @@
@props(['post', 'featured' => false])
<article class="{{ $featured ? 'group relative overflow-hidden rounded-3xl bg-white shadow-xl' : 'group' }}">
@if($featured)
<!-- Featured Post Card -->
<a href="{{ route('post.show', $post) }}" class="block">
<div class="aspect-[4/5] sm:aspect-[3/4] relative overflow-hidden">
@if($post->display_image)
<img src="{{ Storage::url($post->display_image) }}"
alt="{{ $post->title }}"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105">
@else
<div class="w-full h-full bg-gradient-to-br from-rose-100 via-fuchsia-100 to-indigo-100 flex items-center justify-center">
<svg class="w-16 h-16 text-fuchsia-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@endif
<!-- Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
<!-- Content -->
<div class="absolute bottom-0 left-0 right-0 p-6 sm:p-8">
@if($post->category)
<span class="inline-block px-3 py-1 text-xs font-medium bg-white/20 backdrop-blur-md text-white rounded-full mb-3">
{{ $post->category->name }}
</span>
@endif
<h3 class="text-xl sm:text-2xl font-serif font-semibold text-white mb-2 line-clamp-2">{{ $post->title }}</h3>
<p class="text-stone-300 text-sm line-clamp-2 mb-4">{{ $post->excerpt }}</p>
<div class="flex items-center text-stone-400 text-sm">
<span>{{ $post->published_at?->format('Y年m月d日') }}</span>
<span class="mx-2">·</span>
<span>{{ $post->reading_time }} 分钟阅读</span>
</div>
</div>
</div>
</a>
@else
<!-- Regular Post Card -->
<a href="{{ route('post.show', $post) }}" class="block">
<div class="aspect-square relative overflow-hidden rounded-2xl bg-stone-100 mb-4">
@if($post->display_image)
<img src="{{ Storage::url($post->display_image) }}"
alt="{{ $post->title }}"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">
@else
<div class="w-full h-full bg-gradient-to-br from-rose-50 via-fuchsia-50 to-indigo-50 flex items-center justify-center">
<svg class="w-12 h-12 text-fuchsia-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@endif
<!-- Gallery indicator -->
@if($post->gallery && count($post->gallery) > 1)
<div class="absolute top-3 right-3 p-2 bg-black/40 backdrop-blur-sm rounded-lg">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@endif
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300"></div>
</div>
<div class="space-y-2">
@if($post->category)
<span class="inline-block text-xs font-medium text-fuchsia-600 uppercase tracking-wider">
{{ $post->category->name }}
</span>
@endif
<h3 class="font-serif text-lg font-medium text-stone-900 line-clamp-2 group-hover:text-fuchsia-700 transition-colors">
{{ $post->title }}
</h3>
<p class="text-sm text-stone-500 line-clamp-2">{{ $post->excerpt }}</p>
<div class="flex items-center text-xs text-stone-400 pt-1">
<span>{{ $post->published_at?->format('Y/m/d') }}</span>
@if($post->views_count > 0)
<span class="mx-2">·</span>
<span>{{ number_format($post->views_count) }} 阅读</span>
@endif
</div>
</div>
</a>
@endif
</article>

View File

@ -1,7 +1,26 @@
<?php
use App\Http\Controllers\BlogController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
// Livewire 静态资源路由 (修复 404 问题)
Route::get('/livewire/livewire.js', function () {
return response()->file(public_path('vendor/livewire/livewire.min.js'), [
'Content-Type' => 'application/javascript',
]);
});
// 博客首页
Route::get('/', [BlogController::class, 'index'])->name('home');
// 搜索
Route::get('/search', [BlogController::class, 'search'])->name('search');
// 分类文章
Route::get('/category/{category:slug}', [BlogController::class, 'category'])->name('category');
// 标签文章
Route::get('/tag/{tag:slug}', [BlogController::class, 'tag'])->name('tag');
// 文章详情
Route::get('/post/{post:slug}', [BlogController::class, 'show'])->name('post.show');