Add Filament admin panel and creative public portfolio site
Adds a Filament admin (Profile settings page plus CRUD resources for Education, Experience, Projects, and Posts) backing a public-facing personal site: hero/about/timeline home page, project showcase, and blog, styled with a dark editorial theme (Fraunces + Instrument Sans). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,11 @@
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/css/filament
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/fonts/filament
|
||||
/public/hot
|
||||
/public/js/filament
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Profile;
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
|
||||
class ManageProfile extends Page
|
||||
{
|
||||
protected string $view = 'filament.pages.manage-profile';
|
||||
|
||||
protected static ?string $navigationLabel = 'Profile';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUser;
|
||||
|
||||
protected static ?int $navigationSort = -1;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill(Profile::query()->first()?->toArray() ?? []);
|
||||
}
|
||||
|
||||
public function defaultForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema->statePath('data');
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required(),
|
||||
TextInput::make('headline')
|
||||
->helperText('A short tagline shown under your name, e.g. "Backend developer & tinkerer".'),
|
||||
RichEditor::make('bio')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('location'),
|
||||
TextInput::make('email')
|
||||
->email(),
|
||||
FileUpload::make('avatar')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('profile'),
|
||||
FileUpload::make('resume_path')
|
||||
->label('Resume / CV')
|
||||
->disk('public')
|
||||
->directory('profile'),
|
||||
KeyValue::make('social_links')
|
||||
->keyLabel('Platform')
|
||||
->valueLabel('URL')
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$data = $this->form->getState();
|
||||
|
||||
Profile::query()->first()?->update($data) ?? Profile::create($data);
|
||||
|
||||
Notification::make()
|
||||
->title('Profile saved')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Education;
|
||||
|
||||
use App\Filament\Resources\Education\Pages\CreateEducation;
|
||||
use App\Filament\Resources\Education\Pages\EditEducation;
|
||||
use App\Filament\Resources\Education\Pages\ListEducation;
|
||||
use App\Filament\Resources\Education\Schemas\EducationForm;
|
||||
use App\Filament\Resources\Education\Tables\EducationTable;
|
||||
use App\Models\Education;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class EducationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Education::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return EducationForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return EducationTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListEducation::route('/'),
|
||||
'create' => CreateEducation::route('/create'),
|
||||
'edit' => EditEducation::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Education\Pages;
|
||||
|
||||
use App\Filament\Resources\Education\EducationResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateEducation extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EducationResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Education\Pages;
|
||||
|
||||
use App\Filament\Resources\Education\EducationResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditEducation extends EditRecord
|
||||
{
|
||||
protected static string $resource = EducationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Education\Pages;
|
||||
|
||||
use App\Filament\Resources\Education\EducationResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEducation extends ListRecords
|
||||
{
|
||||
protected static string $resource = EducationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Education\Schemas;
|
||||
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class EducationForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('institution')
|
||||
->required(),
|
||||
TextInput::make('degree'),
|
||||
TextInput::make('field_of_study'),
|
||||
DatePicker::make('start_date'),
|
||||
DatePicker::make('end_date'),
|
||||
Textarea::make('description')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('sort_order')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Education\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class EducationTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('sort_order')
|
||||
->columns([
|
||||
TextColumn::make('institution')
|
||||
->searchable(),
|
||||
TextColumn::make('degree')
|
||||
->searchable(),
|
||||
TextColumn::make('field_of_study')
|
||||
->searchable(),
|
||||
TextColumn::make('start_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('end_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('sort_order')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Experiences;
|
||||
|
||||
use App\Filament\Resources\Experiences\Pages\CreateExperience;
|
||||
use App\Filament\Resources\Experiences\Pages\EditExperience;
|
||||
use App\Filament\Resources\Experiences\Pages\ListExperiences;
|
||||
use App\Filament\Resources\Experiences\Schemas\ExperienceForm;
|
||||
use App\Filament\Resources\Experiences\Tables\ExperiencesTable;
|
||||
use App\Models\Experience;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ExperienceResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Experience::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return ExperienceForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ExperiencesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListExperiences::route('/'),
|
||||
'create' => CreateExperience::route('/create'),
|
||||
'edit' => EditExperience::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Experiences\Pages;
|
||||
|
||||
use App\Filament\Resources\Experiences\ExperienceResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateExperience extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ExperienceResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Experiences\Pages;
|
||||
|
||||
use App\Filament\Resources\Experiences\ExperienceResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditExperience extends EditRecord
|
||||
{
|
||||
protected static string $resource = ExperienceResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Experiences\Pages;
|
||||
|
||||
use App\Filament\Resources\Experiences\ExperienceResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListExperiences extends ListRecords
|
||||
{
|
||||
protected static string $resource = ExperienceResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Experiences\Schemas;
|
||||
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ExperienceForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('company')
|
||||
->required(),
|
||||
TextInput::make('role')
|
||||
->required(),
|
||||
TextInput::make('location'),
|
||||
DatePicker::make('start_date'),
|
||||
DatePicker::make('end_date'),
|
||||
Toggle::make('is_current')
|
||||
->default(false),
|
||||
Textarea::make('description')
|
||||
->columnSpanFull(),
|
||||
TextInput::make('sort_order')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Experiences\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ExperiencesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('sort_order')
|
||||
->columns([
|
||||
TextColumn::make('company')
|
||||
->searchable(),
|
||||
TextColumn::make('role')
|
||||
->searchable(),
|
||||
TextColumn::make('location')
|
||||
->searchable(),
|
||||
TextColumn::make('start_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('end_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
IconColumn::make('is_current')
|
||||
->boolean(),
|
||||
TextColumn::make('sort_order')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Pages;
|
||||
|
||||
use App\Filament\Resources\Posts\PostResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePost extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Pages;
|
||||
|
||||
use App\Filament\Resources\Posts\PostResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPost extends EditRecord
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Pages;
|
||||
|
||||
use App\Filament\Resources\Posts\PostResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPosts extends ListRecords
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts;
|
||||
|
||||
use App\Filament\Resources\Posts\Pages\CreatePost;
|
||||
use App\Filament\Resources\Posts\Pages\EditPost;
|
||||
use App\Filament\Resources\Posts\Pages\ListPosts;
|
||||
use App\Filament\Resources\Posts\Schemas\PostForm;
|
||||
use App\Filament\Resources\Posts\Tables\PostsTable;
|
||||
use App\Models\Post;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PostResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Post::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return PostForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return PostsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPosts::route('/'),
|
||||
'create' => CreatePost::route('/create'),
|
||||
'edit' => EditPost::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Schemas;
|
||||
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PostForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(ignoreRecord: true),
|
||||
Textarea::make('excerpt')
|
||||
->columnSpanFull(),
|
||||
RichEditor::make('body')
|
||||
->columnSpanFull(),
|
||||
FileUpload::make('cover_image')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('posts'),
|
||||
TagsInput::make('tags')
|
||||
->columnSpanFull(),
|
||||
DateTimePicker::make('published_at')
|
||||
->helperText('Leave blank to keep this post as a draft.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PostsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('published_at', 'desc')
|
||||
->columns([
|
||||
ImageColumn::make('cover_image')
|
||||
->disk('public'),
|
||||
TextColumn::make('title')
|
||||
->searchable(),
|
||||
TextColumn::make('excerpt')
|
||||
->searchable()
|
||||
->limit(50),
|
||||
TextColumn::make('published_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateProject extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditProject extends EditRecord
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Pages;
|
||||
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProjects extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProjectResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects;
|
||||
|
||||
use App\Filament\Resources\Projects\Pages\CreateProject;
|
||||
use App\Filament\Resources\Projects\Pages\EditProject;
|
||||
use App\Filament\Resources\Projects\Pages\ListProjects;
|
||||
use App\Filament\Resources\Projects\Schemas\ProjectForm;
|
||||
use App\Filament\Resources\Projects\Tables\ProjectsTable;
|
||||
use App\Models\Project;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProjectResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Project::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return ProjectForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ProjectsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListProjects::route('/'),
|
||||
'create' => CreateProject::route('/create'),
|
||||
'edit' => EditProject::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Schemas;
|
||||
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ProjectForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', \Illuminate\Support\Str::slug($state))),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('summary')
|
||||
->columnSpanFull(),
|
||||
RichEditor::make('description')
|
||||
->columnSpanFull(),
|
||||
FileUpload::make('image')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('projects'),
|
||||
TextInput::make('url')
|
||||
->label('Live URL')
|
||||
->url(),
|
||||
TextInput::make('repository_url')
|
||||
->label('Repository URL')
|
||||
->url(),
|
||||
TagsInput::make('tech_stack')
|
||||
->label('Tech stack')
|
||||
->columnSpanFull(),
|
||||
Toggle::make('is_featured')
|
||||
->default(false),
|
||||
TextInput::make('sort_order')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Projects\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProjectsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('sort_order')
|
||||
->columns([
|
||||
ImageColumn::make('image')
|
||||
->disk('public'),
|
||||
TextColumn::make('title')
|
||||
->searchable(),
|
||||
TextColumn::make('summary')
|
||||
->searchable()
|
||||
->limit(50),
|
||||
IconColumn::make('is_featured')
|
||||
->boolean(),
|
||||
TextColumn::make('sort_order')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Education;
|
||||
use App\Models\Experience;
|
||||
use App\Models\Post;
|
||||
use App\Models\Profile;
|
||||
use App\Models\Project;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$profile = Profile::query()->first();
|
||||
|
||||
$timeline = Experience::query()->get()->map(fn (Experience $item) => (object) [
|
||||
'type' => 'work',
|
||||
'title' => $item->role,
|
||||
'subtitle' => $item->company,
|
||||
'start_date' => $item->start_date,
|
||||
'end_date' => $item->end_date,
|
||||
'is_current' => $item->is_current,
|
||||
'description' => $item->description,
|
||||
])->concat(Education::query()->get()->map(fn (Education $item) => (object) [
|
||||
'type' => 'education',
|
||||
'title' => $item->degree
|
||||
? $item->degree . ($item->field_of_study ? " in {$item->field_of_study}" : '')
|
||||
: $item->institution,
|
||||
'subtitle' => $item->institution,
|
||||
'start_date' => $item->start_date,
|
||||
'end_date' => $item->end_date,
|
||||
'is_current' => false,
|
||||
'description' => $item->description,
|
||||
]))->sortByDesc(fn (object $item) => $item->start_date)->values();
|
||||
|
||||
$projects = Project::query()
|
||||
->orderByDesc('is_featured')
|
||||
->orderBy('sort_order')
|
||||
->orderByDesc('created_at')
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
return view('home', compact('profile', 'timeline', 'projects', 'posts'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate(6);
|
||||
|
||||
return view('posts.index', compact('posts'));
|
||||
}
|
||||
|
||||
public function show(Post $post): View
|
||||
{
|
||||
abort_unless($post->published_at?->isPast(), 404);
|
||||
|
||||
return view('posts.show', compact('post'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$projects = Project::query()
|
||||
->orderByDesc('is_featured')
|
||||
->orderBy('sort_order')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return view('projects.index', compact('projects'));
|
||||
}
|
||||
|
||||
public function show(Project $project): View
|
||||
{
|
||||
return view('projects.show', compact('project'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Education extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'institution',
|
||||
'degree',
|
||||
'field_of_study',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'description',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Experience extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company',
|
||||
'role',
|
||||
'location',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'is_current',
|
||||
'description',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_current' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Post extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'excerpt',
|
||||
'body',
|
||||
'cover_image',
|
||||
'tags',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Post $post) {
|
||||
if (blank($post->slug)) {
|
||||
$post->slug = Str::slug($post->title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('published_at')->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
protected function coverImageUrl(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->cover_image ? Storage::disk('public')->url($this->cover_image) : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Profile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'headline',
|
||||
'bio',
|
||||
'location',
|
||||
'email',
|
||||
'avatar',
|
||||
'resume_path',
|
||||
'social_links',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'social_links' => 'array',
|
||||
];
|
||||
|
||||
protected function avatarUrl(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->avatar ? Storage::disk('public')->url($this->avatar) : null);
|
||||
}
|
||||
|
||||
protected function resumeUrl(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->resume_path ? Storage::disk('public')->url($this->resume_path) : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Project extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'summary',
|
||||
'description',
|
||||
'image',
|
||||
'url',
|
||||
'repository_url',
|
||||
'tech_stack',
|
||||
'is_featured',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tech_stack' => 'array',
|
||||
'is_featured' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Project $project) {
|
||||
if (blank($project->slug)) {
|
||||
$project->slug = Str::slug($project->title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
protected function imageUrl(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->image ? Storage::disk('public')->url($this->image) : null);
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -4,6 +4,8 @@ namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -12,11 +14,16 @@ use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
PreventRequestForgery::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
];
|
||||
|
||||
+3
-1
@@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"filament/filament": "^5.0",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
@@ -50,7 +51,8 @@
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
"@php artisan package:discover --ansi",
|
||||
"@php artisan filament:upgrade"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
|
||||
Generated
+2122
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
<?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('profiles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('headline')->nullable();
|
||||
$table->text('bio')->nullable();
|
||||
$table->string('location')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('avatar')->nullable();
|
||||
$table->string('resume_path')->nullable();
|
||||
$table->json('social_links')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('profiles');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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('education', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('institution');
|
||||
$table->string('degree')->nullable();
|
||||
$table->string('field_of_study')->nullable();
|
||||
$table->date('start_date')->nullable();
|
||||
$table->date('end_date')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('education');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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('experiences', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('company');
|
||||
$table->string('role');
|
||||
$table->string('location')->nullable();
|
||||
$table->date('start_date')->nullable();
|
||||
$table->date('end_date')->nullable();
|
||||
$table->boolean('is_current')->default(false);
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('experiences');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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('projects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('summary')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->string('url')->nullable();
|
||||
$table->string('repository_url')->nullable();
|
||||
$table->json('tech_stack')->nullable();
|
||||
$table->boolean('is_featured')->default(false);
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('projects');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('excerpt')->nullable();
|
||||
$table->longText('body')->nullable();
|
||||
$table->string('cover_image')->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('posts');
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Education;
|
||||
use App\Models\Experience;
|
||||
use App\Models\Post;
|
||||
use App\Models\Profile;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
@@ -15,11 +20,79 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
User::firstOrCreate(
|
||||
['email' => 'admin@mikeisdeving.test'],
|
||||
['name' => 'Mike', 'password' => bcrypt('password')],
|
||||
);
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
Profile::query()->firstOrCreate([], [
|
||||
'name' => 'Mike',
|
||||
'headline' => 'Backend developer & tinkerer',
|
||||
'bio' => '<p>I build web apps and occasionally write about it. This is a placeholder bio — edit it from the admin panel.</p>',
|
||||
'location' => 'Somewhere, Earth',
|
||||
'email' => 'hello@mikeisdeving.test',
|
||||
'social_links' => [
|
||||
'github' => 'https://github.com/',
|
||||
'linkedin' => 'https://linkedin.com/',
|
||||
],
|
||||
]);
|
||||
|
||||
if (Experience::query()->count() === 0) {
|
||||
Experience::create([
|
||||
'company' => 'Acme Corp',
|
||||
'role' => 'Senior Developer',
|
||||
'location' => 'Remote',
|
||||
'start_date' => '2023-01-01',
|
||||
'is_current' => true,
|
||||
'description' => 'Working on backend systems and internal tooling.',
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
Experience::create([
|
||||
'company' => 'Startup Inc',
|
||||
'role' => 'Software Developer',
|
||||
'location' => 'Remote',
|
||||
'start_date' => '2020-06-01',
|
||||
'end_date' => '2022-12-01',
|
||||
'description' => 'Built and maintained a SaaS product from early stage to scale.',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
if (Education::query()->count() === 0) {
|
||||
Education::create([
|
||||
'institution' => 'State University',
|
||||
'degree' => 'B.Sc.',
|
||||
'field_of_study' => 'Computer Science',
|
||||
'start_date' => '2016-09-01',
|
||||
'end_date' => '2020-06-01',
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
if (Project::query()->count() === 0) {
|
||||
Project::create([
|
||||
'title' => 'Sample Project',
|
||||
'slug' => 'sample-project',
|
||||
'summary' => 'A short description of what this project does.',
|
||||
'description' => '<p>Longer write-up of the project, the problem it solves, and how it was built.</p>',
|
||||
'url' => 'https://example.com',
|
||||
'repository_url' => 'https://github.com/example/example',
|
||||
'tech_stack' => ['Laravel', 'PHP', 'Tailwind'],
|
||||
'is_featured' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
if (Post::query()->count() === 0) {
|
||||
Post::create([
|
||||
'title' => 'Hello, world',
|
||||
'slug' => 'hello-world',
|
||||
'excerpt' => 'The first post on this blog.',
|
||||
'body' => '<p>This is a placeholder post. Edit or delete it from the admin panel.</p>',
|
||||
'tags' => ['meta'],
|
||||
'published_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1923
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@@ -6,4 +7,19 @@
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-display: 'Fraunces', ui-serif, Georgia, serif;
|
||||
|
||||
--color-ink: #0a0a0d;
|
||||
--color-ink-soft: #131318;
|
||||
--color-paper: #f5f3ee;
|
||||
--color-paper-dim: #cac7bd;
|
||||
--color-accent: #ff5a36;
|
||||
--color-accent-soft: #3a1a10;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
::selection {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@props(['title' => null, 'description' => null])
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $title ? "{$title} · " . config('app.name') : config('app.name') }}</title>
|
||||
@if ($description)
|
||||
<meta name="description" content="{{ $description }}">
|
||||
@endif
|
||||
|
||||
@fonts
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-ink font-sans text-paper antialiased">
|
||||
<div class="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute -top-48 -left-32 h-[34rem] w-[34rem] rounded-full bg-accent/20 blur-[130px]"></div>
|
||||
<div class="absolute top-1/2 -right-40 h-[28rem] w-[28rem] rounded-full bg-accent/10 blur-[150px]"></div>
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(245,243,238,0.06)_1px,transparent_0)] bg-[length:28px_28px]"></div>
|
||||
</div>
|
||||
|
||||
<x-site.nav />
|
||||
|
||||
<main>
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
<x-site.footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
@php
|
||||
$profile = \App\Models\Profile::query()->first();
|
||||
$socials = collect($profile?->social_links ?? []);
|
||||
@endphp
|
||||
<footer class="mt-24 border-t border-white/5">
|
||||
<div class="mx-auto flex max-w-5xl flex-col items-center gap-4 px-6 py-10 text-sm text-paper-dim sm:flex-row sm:justify-between">
|
||||
<p>© {{ now()->year }} {{ $profile?->name ?? config('app.name') }}. All rights reserved.</p>
|
||||
|
||||
@if ($socials->isNotEmpty())
|
||||
<div class="flex items-center gap-5">
|
||||
@foreach ($socials as $platform => $url)
|
||||
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="capitalize transition hover:text-accent">
|
||||
{{ $platform }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,16 @@
|
||||
@php
|
||||
$profile = \App\Models\Profile::query()->first();
|
||||
@endphp
|
||||
<header class="sticky top-0 z-40 border-b border-white/5 bg-ink/80 backdrop-blur-md">
|
||||
<nav class="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<a href="{{ route('home') }}" class="font-display text-lg font-semibold tracking-tight text-paper">
|
||||
{{ $profile?->name ?? config('app.name') }}<span class="text-accent">.</span>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-6 text-sm text-paper-dim">
|
||||
<a href="{{ route('home') }}#about" class="transition hover:text-paper">About</a>
|
||||
<a href="{{ route('projects.index') }}" class="transition hover:text-paper {{ request()->routeIs('projects.*') ? 'text-paper' : '' }}">Projects</a>
|
||||
<a href="{{ route('posts.index') }}" class="transition hover:text-paper {{ request()->routeIs('posts.*') ? 'text-paper' : '' }}">Blog</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,9 @@
|
||||
<x-filament-panels::page>
|
||||
<form wire:submit="save" class="space-y-6">
|
||||
{{ $this->form }}
|
||||
|
||||
<x-filament::button type="submit">
|
||||
Save
|
||||
</x-filament::button>
|
||||
</form>
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,146 @@
|
||||
<x-layouts.app :title="null" :description="$profile?->headline">
|
||||
{{-- Hero --}}
|
||||
<section class="mx-auto flex max-w-5xl flex-col-reverse items-center gap-12 px-6 pt-16 pb-24 sm:pt-24 sm:pb-32 lg:flex-row lg:items-center">
|
||||
<div class="flex-1 text-center lg:text-left">
|
||||
<p class="font-mono text-sm tracking-widest text-accent uppercase">Hi, I'm</p>
|
||||
|
||||
<h1 class="mt-3 font-display text-5xl leading-[1.05] font-semibold text-paper sm:text-6xl lg:text-7xl">
|
||||
{{ $profile?->name ?? 'Someone building things' }}
|
||||
</h1>
|
||||
|
||||
@if ($profile?->headline)
|
||||
<p class="mt-5 font-display text-2xl text-paper-dim italic">{{ $profile->headline }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-4 lg:justify-start">
|
||||
<a href="{{ route('projects.index') }}" class="rounded-full bg-accent px-6 py-3 text-sm font-semibold text-ink transition hover:brightness-110">
|
||||
View my projects
|
||||
</a>
|
||||
<a href="{{ route('posts.index') }}" class="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-paper transition hover:border-white/30">
|
||||
Read the blog
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<div class="absolute inset-0 -z-10 scale-110 rounded-[2.5rem] bg-accent/30 blur-3xl"></div>
|
||||
@if ($profile?->avatar_url)
|
||||
<img src="{{ $profile->avatar_url }}" alt="{{ $profile->name }}" class="h-56 w-56 rounded-[2.5rem] object-cover ring-1 ring-white/10 sm:h-72 sm:w-72">
|
||||
@else
|
||||
<div class="flex h-56 w-56 items-center justify-center rounded-[2.5rem] bg-ink-soft ring-1 ring-white/10 sm:h-72 sm:w-72">
|
||||
<span class="font-display text-6xl text-paper-dim">{{ \Illuminate\Support\Str::substr($profile?->name ?? '?', 0, 1) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- About --}}
|
||||
@if ($profile?->bio)
|
||||
<section id="about" class="mx-auto max-w-3xl scroll-mt-24 px-6 py-16">
|
||||
<h2 class="font-display text-3xl font-semibold text-paper">About</h2>
|
||||
<div class="prose prose-invert mt-6 max-w-none text-paper-dim prose-p:leading-relaxed prose-a:text-accent">
|
||||
{!! $profile->bio !!}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4 text-sm text-paper-dim">
|
||||
@if ($profile->location)
|
||||
<span class="rounded-full border border-white/10 px-4 py-2">📍 {{ $profile->location }}</span>
|
||||
@endif
|
||||
@if ($profile->email)
|
||||
<a href="mailto:{{ $profile->email }}" class="rounded-full border border-white/10 px-4 py-2 transition hover:border-accent hover:text-paper">✉ {{ $profile->email }}</a>
|
||||
@endif
|
||||
@if ($profile->resume_url)
|
||||
<a href="{{ $profile->resume_url }}" target="_blank" rel="noopener noreferrer" class="rounded-full border border-white/10 px-4 py-2 transition hover:border-accent hover:text-paper">↓ Resume</a>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Timeline --}}
|
||||
@if ($timeline->isNotEmpty())
|
||||
<section class="mx-auto max-w-3xl px-6 py-16">
|
||||
<h2 class="font-display text-3xl font-semibold text-paper">Journey</h2>
|
||||
|
||||
<ol class="mt-10 space-y-10 border-l border-white/10 pl-8">
|
||||
@foreach ($timeline as $item)
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[2.35rem] top-1.5 h-3 w-3 rounded-full {{ $item->type === 'work' ? 'bg-accent' : 'bg-paper-dim' }} ring-4 ring-ink"></span>
|
||||
|
||||
<p class="font-mono text-xs tracking-wide text-paper-dim uppercase">
|
||||
{{ $item->start_date?->format('Y') }}
|
||||
—
|
||||
{{ $item->is_current ? 'Present' : ($item->end_date?->format('Y') ?? '') }}
|
||||
<span class="text-accent">· {{ $item->type === 'work' ? 'Work' : 'Education' }}</span>
|
||||
</p>
|
||||
|
||||
<h3 class="mt-1 font-display text-xl font-medium text-paper">{{ $item->title }}</h3>
|
||||
<p class="text-paper-dim">{{ $item->subtitle }}</p>
|
||||
|
||||
@if ($item->description)
|
||||
<p class="mt-2 text-sm leading-relaxed text-paper-dim">{{ $item->description }}</p>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Featured projects --}}
|
||||
@if ($projects->isNotEmpty())
|
||||
<section class="mx-auto max-w-5xl px-6 py-16">
|
||||
<div class="flex items-end justify-between">
|
||||
<h2 class="font-display text-3xl font-semibold text-paper">Projects</h2>
|
||||
<a href="{{ route('projects.index') }}" class="text-sm text-paper-dim transition hover:text-accent">View all →</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid gap-6 sm:grid-cols-2">
|
||||
@foreach ($projects as $project)
|
||||
<a href="{{ route('projects.show', $project) }}" class="group overflow-hidden rounded-2xl border border-white/10 bg-ink-soft transition hover:border-accent/50">
|
||||
@if ($project->image_url)
|
||||
<div class="aspect-video overflow-hidden">
|
||||
<img src="{{ $project->image_url }}" alt="{{ $project->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||
</div>
|
||||
@endif
|
||||
<div class="p-6">
|
||||
<h3 class="font-display text-xl font-medium text-paper">{{ $project->title }}</h3>
|
||||
@if ($project->summary)
|
||||
<p class="mt-2 text-sm text-paper-dim">{{ $project->summary }}</p>
|
||||
@endif
|
||||
@if (!empty($project->tech_stack))
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach (array_slice($project->tech_stack, 0, 4) as $tech)
|
||||
<span class="rounded-full bg-accent-soft px-3 py-1 text-xs text-accent">{{ $tech }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Latest posts --}}
|
||||
@if ($posts->isNotEmpty())
|
||||
<section class="mx-auto max-w-3xl px-6 py-16">
|
||||
<div class="flex items-end justify-between">
|
||||
<h2 class="font-display text-3xl font-semibold text-paper">From the blog</h2>
|
||||
<a href="{{ route('posts.index') }}" class="text-sm text-paper-dim transition hover:text-accent">View all →</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 divide-y divide-white/10">
|
||||
@foreach ($posts as $post)
|
||||
<a href="{{ route('posts.show', $post) }}" class="group flex flex-col gap-1 py-6 sm:flex-row sm:items-baseline sm:gap-6">
|
||||
<span class="font-mono text-xs tracking-wide text-paper-dim uppercase">{{ $post->published_at->format('M j, Y') }}</span>
|
||||
<span class="flex-1">
|
||||
<span class="font-display text-lg text-paper transition group-hover:text-accent">{{ $post->title }}</span>
|
||||
@if ($post->excerpt)
|
||||
<span class="mt-1 block text-sm text-paper-dim">{{ $post->excerpt }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,30 @@
|
||||
<x-layouts.app title="Blog" description="Writing on things I'm learning and building.">
|
||||
<section class="mx-auto max-w-3xl px-6 pt-16 pb-8">
|
||||
<p class="font-mono text-sm tracking-widest text-accent uppercase">Writing</p>
|
||||
<h1 class="mt-3 font-display text-5xl font-semibold text-paper">Blog</h1>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-3xl px-6 pb-16">
|
||||
@if ($posts->isEmpty())
|
||||
<p class="text-paper-dim">No posts yet — check back soon.</p>
|
||||
@else
|
||||
<div class="divide-y divide-white/10">
|
||||
@foreach ($posts as $post)
|
||||
<a href="{{ route('posts.show', $post) }}" class="group flex flex-col gap-1 py-6 sm:flex-row sm:items-baseline sm:gap-6">
|
||||
<span class="font-mono text-xs tracking-wide text-paper-dim uppercase">{{ $post->published_at->format('M j, Y') }}</span>
|
||||
<span class="flex-1">
|
||||
<span class="font-display text-lg text-paper transition group-hover:text-accent">{{ $post->title }}</span>
|
||||
@if ($post->excerpt)
|
||||
<span class="mt-1 block text-sm text-paper-dim">{{ $post->excerpt }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
{{ $posts->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,26 @@
|
||||
<x-layouts.app :title="$post->title" :description="$post->excerpt">
|
||||
<article class="mx-auto max-w-3xl px-6 pt-16 pb-24">
|
||||
<a href="{{ route('posts.index') }}" class="text-sm text-paper-dim transition hover:text-accent">← All posts</a>
|
||||
|
||||
<p class="mt-6 font-mono text-xs tracking-wide text-paper-dim uppercase">{{ $post->published_at->format('F j, Y') }}</p>
|
||||
<h1 class="mt-2 font-display text-4xl font-semibold text-paper sm:text-5xl">{{ $post->title }}</h1>
|
||||
|
||||
@if (!empty($post->tags))
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach ($post->tags as $tag)
|
||||
<span class="rounded-full bg-accent-soft px-3 py-1 text-xs text-accent">{{ $tag }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($post->cover_image_url)
|
||||
<img src="{{ $post->cover_image_url }}" alt="{{ $post->title }}" class="mt-10 w-full rounded-2xl border border-white/10">
|
||||
@endif
|
||||
|
||||
@if ($post->body)
|
||||
<div class="prose prose-invert mt-10 max-w-none text-paper-dim prose-headings:font-display prose-headings:text-paper prose-a:text-accent">
|
||||
{!! $post->body !!}
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,42 @@
|
||||
<x-layouts.app title="Projects" description="A collection of things I've built.">
|
||||
<section class="mx-auto max-w-5xl px-6 pt-16 pb-8">
|
||||
<p class="font-mono text-sm tracking-widest text-accent uppercase">Selected work</p>
|
||||
<h1 class="mt-3 font-display text-5xl font-semibold text-paper">Projects</h1>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-5xl px-6 pb-24">
|
||||
@if ($projects->isEmpty())
|
||||
<p class="text-paper-dim">Nothing here yet — check back soon.</p>
|
||||
@else
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
@foreach ($projects as $project)
|
||||
<a href="{{ route('projects.show', $project) }}" class="group overflow-hidden rounded-2xl border border-white/10 bg-ink-soft transition hover:border-accent/50">
|
||||
@if ($project->image_url)
|
||||
<div class="aspect-video overflow-hidden">
|
||||
<img src="{{ $project->image_url }}" alt="{{ $project->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||
</div>
|
||||
@endif
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-display text-xl font-medium text-paper">{{ $project->title }}</h2>
|
||||
@if ($project->is_featured)
|
||||
<span class="rounded-full bg-accent-soft px-2 py-0.5 text-xs text-accent">Featured</span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($project->summary)
|
||||
<p class="mt-2 text-sm text-paper-dim">{{ $project->summary }}</p>
|
||||
@endif
|
||||
@if (!empty($project->tech_stack))
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@foreach ($project->tech_stack as $tech)
|
||||
<span class="rounded-full bg-white/5 px-3 py-1 text-xs text-paper-dim">{{ $tech }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,42 @@
|
||||
<x-layouts.app :title="$project->title" :description="$project->summary">
|
||||
<article class="mx-auto max-w-3xl px-6 pt-16 pb-24">
|
||||
<a href="{{ route('projects.index') }}" class="text-sm text-paper-dim transition hover:text-accent">← All projects</a>
|
||||
|
||||
<h1 class="mt-6 font-display text-4xl font-semibold text-paper sm:text-5xl">{{ $project->title }}</h1>
|
||||
|
||||
@if ($project->summary)
|
||||
<p class="mt-4 font-display text-xl text-paper-dim italic">{{ $project->summary }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-4">
|
||||
@if ($project->url)
|
||||
<a href="{{ $project->url }}" target="_blank" rel="noopener noreferrer" class="rounded-full bg-accent px-5 py-2.5 text-sm font-semibold text-ink transition hover:brightness-110">
|
||||
Visit live site
|
||||
</a>
|
||||
@endif
|
||||
@if ($project->repository_url)
|
||||
<a href="{{ $project->repository_url }}" target="_blank" rel="noopener noreferrer" class="rounded-full border border-white/15 px-5 py-2.5 text-sm font-semibold text-paper transition hover:border-white/30">
|
||||
View source
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (!empty($project->tech_stack))
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
@foreach ($project->tech_stack as $tech)
|
||||
<span class="rounded-full bg-accent-soft px-3 py-1 text-xs text-accent">{{ $tech }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($project->image_url)
|
||||
<img src="{{ $project->image_url }}" alt="{{ $project->title }}" class="mt-10 w-full rounded-2xl border border-white/10">
|
||||
@endif
|
||||
|
||||
@if ($project->description)
|
||||
<div class="prose prose-invert mt-10 max-w-none text-paper-dim prose-headings:font-display prose-headings:text-paper prose-a:text-accent">
|
||||
{!! $project->description !!}
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,44 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="flex items-center justify-between gap-4">
|
||||
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="cursor-not-allowed rounded-full border border-white/10 px-4 py-2 text-sm text-paper-dim/50">
|
||||
{!! __('pagination.previous') !!}
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="rounded-full border border-white/10 px-4 py-2 text-sm text-paper-dim transition hover:border-accent hover:text-paper">
|
||||
{!! __('pagination.previous') !!}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="hidden items-center gap-1 sm:flex">
|
||||
@foreach ($elements as $element)
|
||||
@if (is_string($element))
|
||||
<span class="px-2 text-sm text-paper-dim/50">{{ $element }}</span>
|
||||
@endif
|
||||
|
||||
@if (is_array($element))
|
||||
@foreach ($element as $page => $url)
|
||||
@if ($page == $paginator->currentPage())
|
||||
<span aria-current="page" class="rounded-full bg-accent px-3.5 py-1.5 text-sm font-semibold text-ink">{{ $page }}</span>
|
||||
@else
|
||||
<a href="{{ $url }}" class="rounded-full px-3.5 py-1.5 text-sm text-paper-dim transition hover:text-paper" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">
|
||||
{{ $page }}
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($paginator->hasMorePages())
|
||||
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="rounded-full border border-white/10 px-4 py-2 text-sm text-paper-dim transition hover:border-accent hover:text-paper">
|
||||
{!! __('pagination.next') !!}
|
||||
</a>
|
||||
@else
|
||||
<span class="cursor-not-allowed rounded-full border border-white/10 px-4 py-2 text-sm text-paper-dim/50">
|
||||
{!! __('pagination.next') !!}
|
||||
</span>
|
||||
@endif
|
||||
</nav>
|
||||
@endif
|
||||
File diff suppressed because one or more lines are too long
+10
-3
@@ -1,7 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\HomeController;
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
Route::get('/', [HomeController::class, 'index'])->name('home');
|
||||
|
||||
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
|
||||
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
|
||||
|
||||
Route::get('/blog', [PostController::class, 'index'])->name('posts.index');
|
||||
Route::get('/blog/{post}', [PostController::class, 'show'])->name('posts.show');
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,10 @@ export default defineConfig({
|
||||
bunny('Instrument Sans', {
|
||||
weights: [400, 500, 600],
|
||||
}),
|
||||
bunny('Fraunces', {
|
||||
weights: [400, 500, 600, 700],
|
||||
styles: ['normal', 'italic'],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tailwindcss(),
|
||||
|
||||
Reference in New Issue
Block a user