Add Tailwind typography plugin, enhance User model, update DatabaseSeeder, improve CSS styles, and define new routes for blog functionality
This commit is contained in:
parent
196dd0db79
commit
509a4e91e5
166
app/Filament/Resources/CategoryResource.php
Normal file
166
app/Filament/Resources/CategoryResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
307
app/Filament/Resources/PostResource.php
Normal file
307
app/Filament/Resources/PostResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/PostResource/Pages/CreatePost.php
Normal file
19
app/Filament/Resources/PostResource/Pages/CreatePost.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
app/Filament/Resources/PostResource/Pages/EditPost.php
Normal file
20
app/Filament/Resources/PostResource/Pages/EditPost.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/PostResource/Pages/ListPosts.php
Normal file
19
app/Filament/Resources/PostResource/Pages/ListPosts.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/PostResource/Pages/ViewPost.php
Normal file
19
app/Filament/Resources/PostResource/Pages/ViewPost.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Filament/Resources/TagResource.php
Normal file
132
app/Filament/Resources/TagResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Filament/Resources/TagResource/Pages/CreateTag.php
Normal file
12
app/Filament/Resources/TagResource/Pages/CreateTag.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
19
app/Filament/Resources/TagResource/Pages/EditTag.php
Normal file
19
app/Filament/Resources/TagResource/Pages/EditTag.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/TagResource/Pages/ListTags.php
Normal file
19
app/Filament/Resources/TagResource/Pages/ListTags.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Http/Controllers/BlogController.php
Normal file
132
app/Http/Controllers/BlogController.php
Normal 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
60
app/Models/Category.php
Normal 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
111
app/Models/Post.php
Normal 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
44
app/Models/Tag.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -3,11 +3,14 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
|
use Filament\Panel;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements FilamentUser
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
@ -45,4 +48,14 @@ class User extends Authenticatable
|
|||||||
'password' => 'hashed',
|
'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
199
config/livewire.php
Normal 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',
|
||||||
|
];
|
||||||
35
database/factories/CategoryFactory.php
Normal file
35
database/factories/CategoryFactory.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
96
database/factories/PostFactory.php
Normal file
96
database/factories/PostFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
41
database/factories/TagFactory.php
Normal file
41
database/factories/TagFactory.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
31
database/migrations/2024_12_24_000002_create_tags_table.php
Normal file
31
database/migrations/2024_12_24_000002_create_tags_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
55
database/migrations/2024_12_24_000003_create_posts_table.php
Normal file
55
database/migrations/2024_12_24_000003_create_posts_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
144
database/seeders/BlogSeeder.php
Normal file
144
database/seeders/BlogSeeder.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -2,24 +2,17 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
use WithoutModelEvents;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed the application's database.
|
* Seed the application's database.
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
$this->call([
|
||||||
|
BlogSeeder::class,
|
||||||
User::factory()->create([
|
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2427
package-lock.json
generated
Normal file
2427
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@
|
|||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
|||||||
11355
public/vendor/livewire/livewire.esm.js
vendored
Normal file
11355
public/vendor/livewire/livewire.esm.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
public/vendor/livewire/livewire.esm.js.map
vendored
Normal file
7
public/vendor/livewire/livewire.esm.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
10468
public/vendor/livewire/livewire.js
vendored
Normal file
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
98
public/vendor/livewire/livewire.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
public/vendor/livewire/livewire.min.js.map
vendored
Normal file
7
public/vendor/livewire/livewire.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
public/vendor/livewire/manifest.json
vendored
Normal file
2
public/vendor/livewire/manifest.json
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
{"/livewire.js":"0f6341c0"}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
@ -6,6 +7,80 @@
|
|||||||
@source '../**/*.js';
|
@source '../**/*.js';
|
||||||
|
|
||||||
@theme {
|
@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';
|
'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;
|
||||||
}
|
}
|
||||||
|
|||||||
67
resources/views/blog/category.blade.php
Normal file
67
resources/views/blog/category.blade.php
Normal 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>
|
||||||
|
|
||||||
82
resources/views/blog/index.blade.php
Normal file
82
resources/views/blog/index.blade.php
Normal 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>
|
||||||
|
|
||||||
83
resources/views/blog/search.blade.php
Normal file
83
resources/views/blog/search.blade.php
Normal 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>
|
||||||
|
|
||||||
177
resources/views/blog/show.blade.php
Normal file
177
resources/views/blog/show.blade.php
Normal 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>
|
||||||
|
|
||||||
66
resources/views/blog/tag.blade.php
Normal file
66
resources/views/blog/tag.blade.php
Normal 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>
|
||||||
|
|
||||||
138
resources/views/components/layout.blade.php
Normal file
138
resources/views/components/layout.blade.php
Normal 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>© {{ date('Y') }} Muse Blog. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
90
resources/views/components/post-card.blade.php
Normal file
90
resources/views/components/post-card.blade.php
Normal 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>
|
||||||
|
|
||||||
@ -1,7 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\BlogController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
// Livewire 静态资源路由 (修复 404 问题)
|
||||||
return view('welcome');
|
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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user