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:
m105
2026-07-30 21:10:43 +03:00
co-authored by Claude Sonnet 5
parent ac61c77608
commit 8d97dcbd0f
62 changed files with 5933 additions and 237 deletions
+3
View File
@@ -15,8 +15,11 @@
/auth.json /auth.json
/node_modules /node_modules
/public/build /public/build
/public/css/filament
/public/fonts-manifest.dev.json /public/fonts-manifest.dev.json
/public/fonts/filament
/public/hot /public/hot
/public/js/filament
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
+80
View File
@@ -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(),
]),
]);
}
}
+53
View File
@@ -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'));
}
}
+26
View File
@@ -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'));
}
}
+23
View File
@@ -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',
];
}
+25
View File
@@ -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',
];
}
+51
View File
@@ -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);
}
}
+35
View File
@@ -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);
}
}
+48
View File
@@ -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
View File
@@ -4,6 +4,8 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -12,11 +14,16 @@ use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])] #[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable implements FilamentUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
public function canAccessPanel(Panel $panel): bool
{
return true;
}
/** /**
* Get the attributes that should be cast. * 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,
]);
}
}
+2 -3
View File
@@ -1,7 +1,6 @@
<?php <?php
use App\Providers\AppServiceProvider;
return [ return [
AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
]; ];
+3 -1
View File
@@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"filament/filament": "^5.0",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/tinker": "^3.0" "laravel/tinker": "^3.0"
}, },
@@ -50,7 +51,8 @@
], ],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi" "@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
], ],
"post-update-cmd": [ "post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force" "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
Generated
+2122 -1
View File
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');
}
};
+77 -4
View File
@@ -2,6 +2,11 @@
namespace Database\Seeders; 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 App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -15,11 +20,79 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); User::firstOrCreate(
['email' => 'admin@mikeisdeving.test'],
['name' => 'Mike', 'password' => bcrypt('password')],
);
User::factory()->create([ Profile::query()->firstOrCreate([], [
'name' => 'Test User', 'name' => 'Mike',
'email' => 'test@example.com', '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(),
]);
}
} }
} }
+1923
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite" "dev": "vite"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
+16
View File
@@ -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,4 +7,19 @@
@theme { @theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', --font-sans: 'Instrument 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-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>&copy; {{ 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>
+146
View File
@@ -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') }}
&mdash;
{{ $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 &rarr;</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 &rarr;</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>
+30
View File
@@ -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>
+26
View File
@@ -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">&larr; 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>
+42
View File
@@ -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>
+42
View File
@@ -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">&larr; 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>
+44
View File
@@ -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
View File
@@ -1,7 +1,14 @@
<?php <?php
use App\Http\Controllers\HomeController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\ProjectController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', [HomeController::class, 'index'])->name('home');
return view('welcome');
}); 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');
+3 -1
View File
@@ -2,11 +2,13 @@
namespace Tests\Feature; namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
class ExampleTest extends TestCase class ExampleTest extends TestCase
{ {
use RefreshDatabase;
/** /**
* A basic test example. * A basic test example.
*/ */
+4
View File
@@ -12,6 +12,10 @@ export default defineConfig({
bunny('Instrument Sans', { bunny('Instrument Sans', {
weights: [400, 500, 600], weights: [400, 500, 600],
}), }),
bunny('Fraunces', {
weights: [400, 500, 600, 700],
styles: ['normal', 'italic'],
}),
], ],
}), }),
tailwindcss(), tailwindcss(),