first commit

This commit is contained in:
jayson-temporas
2025-06-21 14:29:42 +08:00
commit 7fd48f7630
17 changed files with 8596 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/vendor
/node_modules
/.phpunit.cache
/.fleet
/.idea
/.vscode
.DS_Store

272
README.MD Normal file
View File

@@ -0,0 +1,272 @@
# Page Bookmarks
A simple bookmark management system for Laravel Filament applications. This package provides an intuitive way for users to save, organize, and access bookmarks directly within your Admin panel.
## Features
- 📚 **Smart Bookmark Creation**: Automatically captures the current page URL and title
- 📁 **Folder Organization**: Organize bookmarks into custom folders
- 🔍 **Real-time Search**: Search through your bookmarks with instant filtering
- ⌨️ **Keyboard Shortcuts**: Quick bookmark creation shortcut `Cmd+Shift+B` on Mac or `Ctrl+Shift+B` on Windows/Linux
- 👤 **User-specific**: Each user has their own private bookmarks
- 🎯 **Customizable**: Configurable icons, render hooks, table names
## Screenshots
### Add Bookmark
![Bookmark Manager](assets/add-bookmark.png)
### View Bookmarks
![Bookmark Viewer](assets/view-bookmarks.png)
## Requirements
- PHP 8.3+
- Laravel 10+
- Filament 3.2+
- Livewire 3+
## Installation
1. **Install the package via Composer:**
```bash
composer require jaysontemporas/page-bookmarks
```
2. **Publish and run the installation command:**
```bash
php artisan page-bookmarks:install
```
This command will:
- Publish the configuration file
- Publish and run the database migrations
- Publish the assets
## Configuration
### Basic Configuration
The package configuration file is located at `config/page-bookmarks.php`. Here are the main configuration options:
```php
return [
// Table names (customizable to avoid conflicts)
'tables' => [
'bookmarks' => 'bookmarks',
'bookmark_folders' => 'bookmark_folders',
],
// User model for bookmark associations
'models' => [
'user' => \App\Models\User::class,
],
// Icons used throughout the interface
'icons' => [
'add_bookmark' => 'heroicon-o-folder-plus',
'view_bookmarks' => 'heroicon-o-bookmark',
'bookmark_item' => 'heroicon-o-bookmark',
'folder' => 'heroicon-o-folder',
'search' => 'heroicon-o-magnifying-glass',
'delete' => 'heroicon-o-trash',
'chevron_down' => 'heroicon-o-chevron-down',
'empty_state' => 'heroicon-o-bookmark',
],
// Render hook positions in Filament
'render_hooks' => [
'add_bookmark' => \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER,
'view_bookmarks' => \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER,
],
];
```
This package utilizes Filament's theming system, so you'll need to set up a custom theme to properly style all components.
> [!NOTE] Before proceeding, ensure you have configured a custom theme if you're using Filament Panels. Check the [Filament documentation on themes](https://filamentphp.com/docs/3.x/panels/themes) for detailed instructions. This step is required for both the Panels Package and standalone Forms package.
To properly compile all the package styles, update your Tailwind configuration by adding the package's view paths
### Customizing Render Hooks
You can customize where the bookmark components appear in your Filament panel by modifying the `render_hooks` configuration:
```php
'render_hooks' => [
'add_bookmark' => \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER,
'view_bookmarks' => \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER,
],
```
For a complete list of available render hook options, please refer to the [official Filament documentation](https://filamentphp.com/docs/3.x/support/render-hooks). The documentation includes all available `PanelsRenderHook` constants and their specific use cases.
## Usage
### Adding the HasBookmarks Trait
To enable bookmark functionality for your User model, add the `HasBookmarks` trait:
```php
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use JaysonTemporas\PageBookmarks\Traits\HasBookmarks;
class User extends Authenticatable
{
use HasBookmarks;
// ... rest of your User model
}
```
### Using Bookmarks in Your Application
Once the trait is added, you can use the bookmark functionality in your application:
```php
// Get user's bookmarks
$user = auth()->user();
$bookmarks = $user->bookmarks;
// Get bookmark folders
$folders = $user->bookmarkFolders;
// Create a new bookmark
$bookmark = $user->createBookmark([
'name' => 'My Bookmark',
'url' => 'https://example.com',
'bookmark_folder_id' => $folder->id, // optional
]);
// Create a new folder
$folder = $user->createBookmarkFolder([
'name' => 'Work Bookmarks',
]);
// Get bookmarks in a specific folder
$workBookmarks = $user->bookmarksInFolder($folder);
// Check if user has bookmarks
if ($user->hasBookmarks()) {
// User has bookmarks
}
// Get bookmark counts
$bookmarkCount = $user->getBookmarksCount();
$folderCount = $user->getBookmarkFoldersCount();
```
### Available Methods
The `HasBookmarks` trait provides the following methods:
- `bookmarks()` - Get all bookmarks for the user
- `bookmarkFolders()` - Get all bookmark folders for the user
- `rootBookmarks()` - Get bookmarks not in any folder
- `createBookmarkFolder(array $attributes)` - Create a new bookmark folder
- `createBookmark(array $attributes)` - Create a new bookmark
- `bookmarksInFolder(BookmarkFolder $folder)` - Get bookmarks in a specific folder
- `bookmarksInFolderById(int $folderId)` - Get bookmarks in a folder by ID
- `hasBookmarks()` - Check if user has any bookmarks
- `hasBookmarkFolders()` - Check if user has any bookmark folders
- `getBookmarksCount()` - Get total bookmark count
- `getBookmarkFoldersCount()` - Get total folder count
## User Interface
### Adding Bookmarks
1. **Click the bookmark icon** in the Filament panel header
2. **Use keyboard shortcut** (default: `Cmd+Shift+B` on Mac or `Ctrl+Shift+B` on Windows/Linux)
3. The current page URL and title will be automatically captured
4. Enter a custom name (optional - defaults to page title)
5. Select or create a folder (optional)
6. Click "Save"
### Viewing Bookmarks
1. **Click the bookmark viewer icon** in the Filament panel header
2. Browse your bookmarks organized by folders
3. Use the search bar to filter bookmarks
4. Click on any bookmark to navigate to the saved URL
5. Hover over bookmarks to reveal the delete button
### Managing Bookmarks
- **Search**: Type in the search bar to filter bookmarks by name
- **Folders**: Click on folder headers to expand/collapse
- **Delete**: Hover over a bookmark and click the trash icon to delete
- **Navigation**: Click on any bookmark to navigate to the saved URL
## Database Schema
The package creates two tables:
### `bookmarks` table
- `id` - Primary key
- `user_id` - Foreign key to users table
- `name` - Bookmark name/title
- `bookmark_folder_id` - Foreign key to bookmark_folders table (nullable)
- `url` - The saved URL
- `created_at` - Creation timestamp
- `updated_at` - Last update timestamp
### `bookmark_folders` table
- `id` - Primary key
- `user_id` - Foreign key to users table
- `name` - Folder name
- `created_at` - Creation timestamp
- `updated_at` - Last update timestamp
## Customization
### Custom Icons
You can customize the icons used throughout the interface by modifying the `icons` configuration:
```php
'icons' => [
'add_bookmark' => 'heroicon-o-bookmark-square',
'view_bookmarks' => 'heroicon-o-bookmark',
// ... other icons
],
```
### Custom Table Names
If you need to avoid table name conflicts, you can customize the table names:
```php
'tables' => [
'bookmarks' => 'my_custom_bookmarks',
'bookmark_folders' => 'my_custom_bookmark_folders',
],
```
### Custom User Model
If you have a custom User model, update the configuration:
```php
'models' => [
'user' => \App\Models\CustomUser::class,
],
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This package is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
## Support
If you encounter any issues or have questions, please open an issue on the GitHub repository.

BIN
assets/add_bookmark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

BIN
assets/view_bookmark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

33
composer.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "jaysontemporas/page-bookmarks",
"version": "1.0.0",
"type": "library",
"require-dev": {
"php": "^8.3",
"laravel/pint": "^1.17"
},
"license": "MIT",
"autoload": {
"psr-4": {
"Jaysontemporas\\PageBookmarks\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Jaysontemporas\\PageBookmarks\\PageBookmarksServiceProvider"
]
}
},
"authors": [
{
"name": "Jayson Temporas"
}
],
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"filament/filament": "^3.2",
"spatie/laravel-package-tools": "^1.18"
}
}

7340
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

87
config/page-bookmarks.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pages Bookmarks Configuration
|--------------------------------------------------------------------------
*/
/*
|--------------------------------------------------------------------------
| Table Names
|--------------------------------------------------------------------------
|
| Here you can customize the table names used by the page bookmarks package.
| You can change these if you need to avoid conflicts with existing tables
| or prefer different naming conventions.
|
*/
'tables' => [
'bookmarks' => 'bookmarks',
'bookmark_folders' => 'bookmark_folders',
],
/*
|--------------------------------------------------------------------------
| User Model
|--------------------------------------------------------------------------
|
| This is the model that will be used to associate bookmarks with users.
| You can change this to your own User model if you have a custom one.
|
*/
'models' => [
'user' => \App\Models\User::class,
],
/*
|--------------------------------------------------------------------------
| Modal Configuration
|--------------------------------------------------------------------------
|
| Configure how the bookmark modals should be displayed.
| Options: 'modal' or 'slideOver'
| Default: 'slideOver' for both
|
*/
'modal' => [
'add_bookmark' => 'slideOver', // 'modal' or 'modal'
'view_bookmarks' => 'slideOver', // 'modal' or 'slideOver'
],
/*
|--------------------------------------------------------------------------
| Icons Configuration
|--------------------------------------------------------------------------
|
| Configure the icons used throughout the page bookmarks interface.
| You can use any Heroicon or custom icon names that are available
| in your Filament installation.
|
*/
'icons' => [
'add_bookmark' => 'heroicon-o-folder-plus',
'view_bookmarks' => 'heroicon-o-bookmark',
'bookmark_item' => 'heroicon-o-bookmark',
'folder' => 'heroicon-o-folder',
'search' => 'heroicon-o-magnifying-glass',
'delete' => 'heroicon-o-trash',
'chevron_down' => 'heroicon-o-chevron-down',
'empty_state' => 'heroicon-o-bookmark',
],
/*
|--------------------------------------------------------------------------
| Render Hooks Configuration
|--------------------------------------------------------------------------
|
| Configure where the bookmark manager and viewer components should be
| rendered in the Filament panel. You can use any of the available
| PanelsRenderHook constants from Filament\View\PanelsRenderHook.
*/
'render_hooks' => [
'add_bookmark' => \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER,
'view_bookmarks' => \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER,
],
];

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$bookmarkFoldersTable = config('page-bookmarks.tables.bookmark_folders', 'bookmark_folders');
$bookmarksTable = config('page-bookmarks.tables.bookmarks', 'bookmarks');
Schema::create($bookmarkFoldersTable, function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
});
Schema::create($bookmarksTable, function (Blueprint $table) use ($bookmarkFoldersTable): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->foreignId('bookmark_folder_id')->nullable()->constrained($bookmarkFoldersTable)->nullOnDelete();
$table->text('url');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists(config('page-bookmarks.tables.bookmarks', 'bookmarks'));
Schema::dropIfExists(config('page-bookmarks.tables.bookmark_folders', 'bookmark_folders'));
}
};

View File

@@ -0,0 +1,42 @@
<div class="flex justify-end">
<!-- Bookmark Icon Button -->
<x-filament::icon-button
icon="{{ $this->getIcons()['add_bookmark'] }}"
class="text-gray-500 transition-colors hover:text-primary-500"
x-on:click="$dispatch('open-modal', { id: 'bookmark-form-modal' }); $nextTick(() => {
// Try to get the title from h1 tag
const h1 = document.querySelector('h1');
const pageTitle = h1 ? h1.textContent.trim() : document.title;
// Dispatch event to Livewire to set the title
$wire.setBookmarkName(pageTitle);
})"
x-on:keydown.meta.shift.b.prevent.document="$dispatch('open-modal', { id: 'bookmark-form-modal' }); $nextTick(() => {
// Try to get the title from h1 tag
const h1 = document.querySelector('h1');
const pageTitle = h1 ? h1.textContent.trim() : document.title;
// Dispatch event to Livewire to set the title
$wire.setBookmarkName(pageTitle);
})"
/>
<x-filament::modal
id="bookmark-form-modal"
width="md"
:slide-over="config('page-bookmarks.modal.add_bookmark') === 'slideOver' ? true : false"
heading="Add Bookmark"
>
<form wire:submit.prevent="save">
{{ $this->form }}
<div class="flex justify-end mt-6 gap-x-2">
<x-filament::button type="submit">
Save
</x-filament::button>
</div>
</form>
</x-filament::modal>
<x-filament-actions::modals />
</div>

View File

@@ -0,0 +1,164 @@
<div class="flex">
<x-filament::icon-button
icon="{{ $this->getIcons()['view_bookmarks'] }}"
color="gray"
x-on:click="$dispatch('open-modal', { id: 'bookmark-items-modal' })"
/>
<x-filament::modal
id="bookmark-items-modal"
width="md"
heading="My Bookmarks"
:slide-over="config('page-bookmarks.modal.view_bookmarks') === 'slideOver' ? true : false"
x-on:open-modal.window="if ($event.detail.id === 'bookmark-items-modal') $wire.$refresh()"
x-on:refreshBookmarks.window="$wire.$refresh()"
>
<div class="px-2 mb-4">
<div class="relative">
<div class="absolute inset-y-0 flex items-center pointer-events-none start-0 ps-3">
<x-filament::icon
icon="{{ $this->getIcons()['search'] }}"
class="w-4 h-4 text-gray-400"
/>
</div>
<input
type="search"
class="w-full p-2 text-sm text-gray-900 border border-gray-300 rounded-lg ps-10 bg-gray-50 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Search bookmarks"
x-data
x-on:input.debounce.300ms="
const searchTerm = $event.target.value.toLowerCase();
// Get all bookmark items
const bookmarkItems = document.querySelectorAll('[data-bookmark-item]');
// Track visible items per folder
const folderVisibleCount = {};
// First check all bookmark items
bookmarkItems.forEach(item => {
const name = item.getAttribute('data-bookmark-name').toLowerCase();
const folder = item.getAttribute('data-bookmark-folder');
const isVisible = name.includes(searchTerm);
item.style.display = isVisible ? 'flex' : 'none';
// Count visible items
if (isVisible) {
folderVisibleCount[folder] = (folderVisibleCount[folder] || 0) + 1;
}
});
// Then show/hide folder containers
document.querySelectorAll('[data-folder-container]').forEach(folder => {
const folderName = folder.getAttribute('data-folder-name');
const counter = folder.querySelector('[data-folder-counter]');
const visibleCount = folderVisibleCount[folderName] || 0;
if (counter) {
counter.textContent = visibleCount;
}
folder.style.display = visibleCount > 0 ? 'block' : 'none';
});
"
>
</div>
</div>
<div
class="px-2 -mx-2"
wire:key="bookmarks-list"
>
@forelse($this->bookmarksByFolder as $folder => $bookmarks)
<div
wire:key="folder-{{ $loop->index }}"
x-data="{ open: true }"
class="mb-3"
data-folder-container
data-folder-name="{{ $folder }}"
>
<div
class="flex items-center justify-between px-2 py-2 text-sm font-medium rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
@click="open = !open"
>
<div class="flex items-center">
<x-filament::icon
icon="{{ $this->getIcons()['folder'] }}"
class="w-5 h-5 mr-2 text-gray-400 shrink-0 dark:text-gray-500"
/>
<span class="text-gray-700 dark:text-gray-300">
{{ ucfirst($folder) }}
</span>
</div>
<div class="flex items-center">
<span
class="px-1.5 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 mr-1"
data-folder-counter
>{{ count($bookmarks) }}</span>
<x-filament::icon
icon="{{ $this->getIcons()['chevron_down'] }}"
class="w-4 h-4 text-gray-400 transition-transform duration-300"
x-bind:class="{ 'rotate-0': open, '-rotate-90': !open }"
/>
</div>
</div>
<div
class="mt-1 space-y-1"
x-show="open"
x-collapse
>
<div class="pl-4 ml-3 border-l border-gray-200 dark:border-gray-700">
@foreach ($bookmarks as $bookmark)
<div
class="relative flex items-center justify-between px-2 py-1.5 mb-1 text-gray-600 group dark:text-gray-400 hover:bg-primary-500/10 hover:text-primary-600 dark:hover:text-primary-500 rounded-md transition duration-150"
wire:key="bookmark-{{ $bookmark->id }}"
data-bookmark-item
data-bookmark-name="{{ $bookmark->name }}"
data-bookmark-folder="{{ $folder }}"
>
<a
href="{{ $bookmark->url }}"
class="flex items-center w-full truncate"
title="{{ $bookmark->name }}"
>
<x-filament::icon
icon="{{ $this->getIcons()['bookmark_item'] }}"
class="w-4 h-4 mr-2 text-gray-400 shrink-0 dark:text-gray-500"
/>
<span class="text-sm truncate">{{ $bookmark->name }}</span>
</a>
<div class="transition duration-300 opacity-0 group-hover:opacity-100">
<x-filament::icon-button
icon="{{ $this->getIcons()['delete'] }}"
color="danger"
size="xs"
wire:click.stop="deleteBookmark({{ $bookmark->id }})"
wire:loading.attr="disabled"
wire:target="deleteBookmark({{ $bookmark->id }})"
class="ml-auto"
/>
</div>
</div>
@endforeach
</div>
</div>
</div>
@empty
<div class="py-6 text-center">
<x-filament::icon
icon="{{ $this->getIcons()['empty_state'] }}"
class="w-12 h-12 mx-auto text-gray-400"
/>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No bookmarks found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Click the bookmark plus icon to save your first bookmark.
</p>
</div>
@endforelse
</div>
</x-filament::modal>
</div>

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace JaysonTemporas\PageBookmarks\Livewire;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use JaysonTemporas\PageBookmarks\Models\Bookmark;
use JaysonTemporas\PageBookmarks\Models\BookmarkFolder;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* @property Form $form
*/
class BookmarkManager extends Component implements HasForms
{
use InteractsWithForms;
/** @var array<string, mixed> */
public ?array $data = [];
public function mount(): void
{
// Get current URL from the request
$currentUrl = request()->url();
// Initialize data with the current URL
$this->data = [
'url' => $currentUrl,
'display_url' => $currentUrl,
];
}
/**
* Get available bookmark folders for the current user
*
* @return array<int, string>
*/
#[Computed]
public function availableBookmarkFolders(): array
{
$user = auth()->user();
if ($user === null) {
return [];
}
/** @var array<int, string> $folders */
$folders = BookmarkFolder::query()->where('user_id', $user->id)
->pluck('name', 'id')
->toArray();
return $folders;
}
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('name')
->required()
->maxLength(255),
Hidden::make('url'),
Select::make('bookmark_folder_id')
->label('Folder')
->options(BookmarkFolder::query()->where('user_id', auth()->id())->pluck('name', 'id'))
->createOptionForm([
TextInput::make('name')
->required()
->maxLength(255),
])
->createOptionUsing(function (array $data, Set $set) {
$user = auth()->user();
if ($user === null) {
return null;
}
return $user->bookmarkFolders()->create($data)->getKey();
})
->nullable(),
TextInput::make('display_url')
->label('URL')
->disabled()
->dehydrated(false),
])
->model(Bookmark::class)
->statePath('data');
}
/**
* Set the bookmark name from JavaScript
*/
public function setBookmarkName(string $name): void
{
$this->data['name'] = $name;
}
/**
* Get bookmarks organized by folders
*
* @return Collection<string, Collection<int, Bookmark>>
*/
#[Computed]
public function bookmarksByFolder(): Collection
{
$user = auth()->user();
if ($user === null) {
return collect();
}
$bookmarks = Bookmark::query()->where('user_id', $user->id)
->with('folder')
->orderBy('name')
->get();
// Group by bookmark folder (or 'Uncategorized' if folder is null)
$grouped = $bookmarks->groupBy(function (Bookmark $bookmark) {
if ($bookmark->folder) {
return $bookmark->folder->name;
}
// Fallback to the old folder field for backward compatibility
return $bookmark->folder ?: 'Uncategorized';
});
/** @var Collection<string, Collection<int, Bookmark>> */
$result = collect();
// Convert to the right Collection types for PHPStan
foreach ($grouped as $folder => $items) {
/** @var Collection<int, Bookmark> */
$bookmarkCollection = collect($items);
$result->put($folder, $bookmarkCollection);
}
return $result;
}
/**
* Delete a bookmark
*/
public function deleteBookmark(int $id): void
{
$bookmark = Bookmark::query()->where('user_id', auth()->id())->find($id);
if ($bookmark) {
$bookmark->delete();
Notification::make()
->duration(1200)
->title('Bookmark deleted successfully')
->success()
->send();
}
}
/**
* Refresh bookmarks from event
*/
#[On('refreshBookmarks')]
public function refreshBookmarks(): void
{
// This will refresh the computed property
}
/**
* Get configured icons
*
* @return array<string, string>
*/
public function getIcons(): array
{
return config('page-bookmarks.icons', [
'bookmark_manager' => 'heroicon-o-folder-plus',
'bookmark_viewer' => 'heroicon-o-bookmark',
'bookmark_item' => 'heroicon-o-bookmark',
'folder' => 'heroicon-o-folder',
'search' => 'heroicon-o-magnifying-glass',
'delete' => 'heroicon-o-trash',
'chevron_down' => 'heroicon-o-chevron-down',
'empty_state' => 'heroicon-o-bookmark',
]);
}
public function render(): View
{
return view('page-bookmarks::livewire.bookmark-manager');
}
public function save(): void
{
/** @var array<string, mixed> $data */
$data = $this->form->getState();
$user = auth()->user();
if ($user === null) {
return;
}
// Extract and sanitize input values
$name = isset($data['name']) && is_string($data['name']) ? $data['name'] : '';
$url = isset($data['url']) && is_string($data['url']) ? $data['url'] : request()->url();
$bookmarkFolderId = isset($data['bookmark_folder_id']) && is_numeric($data['bookmark_folder_id'])
? (int) $data['bookmark_folder_id']
: null;
// Check for existing bookmarks with the same name or URL for this user
$existingBookmark = Bookmark::query()->where('user_id', $user->id)
->where(function ($query) use ($name, $url): void {
$query->where('name', $name)
->orWhere('url', $url);
})
->first();
if ($existingBookmark) {
// Determine if it's a duplicate name, URL, or both
$duplicateField = '';
if ($existingBookmark->name === $name && $existingBookmark->url === $url) {
$duplicateField = 'bookmark with this name and URL';
} elseif ($existingBookmark->name === $name) {
$duplicateField = 'bookmark with this name';
} else {
$duplicateField = 'bookmark for this URL';
}
Notification::make()
->title('You already have a '.$duplicateField)
->warning()
->send();
return;
}
// Create and save the new bookmark
$bookmark = new Bookmark;
$bookmark->user_id = $user->id;
$bookmark->name = $name;
$bookmark->url = $url;
$bookmark->bookmark_folder_id = $bookmarkFolderId;
$bookmark->save();
$this->form->fill([
'url' => request()->url(),
]);
$this->dispatch('close-modal', id: 'bookmark-form-modal');
$this->dispatch('refreshBookmarks');
Notification::make()
->title('Bookmark saved successfully')
->success()
->send();
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace JaysonTemporas\PageBookmarks\Livewire;
use Filament\Notifications\Notification;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use JaysonTemporas\PageBookmarks\Models\Bookmark;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Lazy;
use Livewire\Attributes\On;
use Livewire\Component;
#[Lazy]
class BookmarkViewer extends Component
{
/**
* Get bookmarks organized by folders
*
* @return Collection<string, Collection<int, Bookmark>>
*/
#[Computed]
public function bookmarksByFolder(): Collection
{
$user = auth()->user();
if ($user === null) {
return collect();
}
$bookmarks = Bookmark::query()->where('user_id', $user->id)
->with('folder')
->orderBy('name')
->get();
// Group by bookmark folder (or 'Uncategorized' if folder is null)
$grouped = $bookmarks->groupBy(function (Bookmark $bookmark) {
if ($bookmark->folder) {
return $bookmark->folder->name;
}
// Fallback to the old folder field for backward compatibility
return $bookmark->folder ?: 'Uncategorized';
});
/** @var Collection<string, Collection<int, Bookmark>> */
$result = collect();
// Convert to the right Collection types for PHPStan
foreach ($grouped as $folder => $items) {
/** @var Collection<int, Bookmark> */
$bookmarkCollection = collect($items);
$result->put($folder, $bookmarkCollection);
}
return $result;
}
/**
* Delete a bookmark
*/
public function deleteBookmark(int $id): void
{
$bookmark = Bookmark::query()->where('user_id', auth()->id())->find($id);
if ($bookmark) {
$bookmark->delete();
Notification::make()
->duration(2000)
->title('Bookmark deleted successfully')
->success()
->send();
$this->dispatch('refreshBookmarks');
}
}
/**
* Refresh bookmarks from event
*/
#[On('refreshBookmarks')]
public function refreshBookmarks(): void
{
// This will refresh the computed property
}
/**
* Get configured icons
*
* @return array<string, string>
*/
public function getIcons(): array
{
return config('page-bookmarks.icons', [
'bookmark_manager' => 'heroicon-o-folder-plus',
'bookmark_viewer' => 'heroicon-o-bookmark',
'bookmark_item' => 'heroicon-o-bookmark',
'folder' => 'heroicon-o-folder',
'search' => 'heroicon-o-magnifying-glass',
'delete' => 'heroicon-o-trash',
'chevron_down' => 'heroicon-o-chevron-down',
'empty_state' => 'heroicon-o-bookmark',
]);
}
public function render(): View
{
return view('page-bookmarks::livewire.bookmark-viewer');
}
}

26
src/Models/Bookmark.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace JaysonTemporas\PageBookmarks\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Bookmark extends Model
{
protected $fillable = [
'user_id',
'name',
'bookmark_folder_id',
'url',
];
public function user(): BelongsTo
{
return $this->belongsTo(config('page-bookmarks.models.user', config('auth.providers.users.model')));
}
public function folder(): BelongsTo
{
return $this->belongsTo(BookmarkFolder::class, 'bookmark_folder_id');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace JaysonTemporas\PageBookmarks\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BookmarkFolder extends Model
{
protected $fillable = [
'user_id',
'name',
];
public function user(): BelongsTo
{
return $this->belongsTo(config('page-bookmarks.models.user', config('auth.providers.users.model')));
}
public function bookmarks(): HasMany
{
return $this->hasMany(Bookmark::class, 'bookmark_folder_id');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace JaysonTemporas\PageBookmarks;
use Filament\Contracts\Plugin;
use Filament\Panel;
class PageBookmarksPlugin implements Plugin
{
public function getId(): string
{
return 'jaysontemporas-page-bookmarks';
}
public function register(Panel $panel): void
{
$panel
->resources([
]);
}
public function boot(Panel $panel): void
{
//
}
public static function make(): static
{
return new static;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace JaysonTemporas\PageBookmarks;
use Filament\Support\Facades\FilamentView;
use Illuminate\Support\Facades\Blade;
use JaysonTemporas\PageBookmarks\Livewire\BookmarkManager;
use JaysonTemporas\PageBookmarks\Livewire\BookmarkViewer;
use Livewire\Livewire;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
class PageBookmarksServiceProvider extends PackageServiceProvider
{
public static string $name = 'page-bookmarks';
public function configurePackage(Package $package): void
{
$package
->name(static::$name)
->hasConfigFile()
->hasMigration('create_bookmarks_table')
->hasViews('page-bookmarks')
// Publishing groups
->hasInstallCommand(function ($command) {
$command
->publishConfigFile()
->publishMigrations()
->publishAssets()
->askToRunMigrations();
});
}
public function packageBooted(): void
{
// Register the Livewire component
Livewire::component('page-bookmarks::livewire.bookmark-manager', BookmarkManager::class);
Livewire::component('page-bookmarks::livewire.bookmark-viewer', BookmarkViewer::class);
FilamentView::registerRenderHook(
config('page-bookmarks.render_hooks.add_bookmark', \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER),
fn (): string => Blade::render("@livewire('page-bookmarks::livewire.bookmark-manager')"),
);
FilamentView::registerRenderHook(
config('page-bookmarks.render_hooks.view_bookmarks', \Filament\View\PanelsRenderHook::GLOBAL_SEARCH_AFTER),
fn (): string => Blade::render("@livewire('page-bookmarks::livewire.bookmark-viewer')"),
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace JaysonTemporas\PageBookmarks\Traits;
use Illuminate\Database\Eloquent\Relations\HasMany;
use JaysonTemporas\PageBookmarks\Models\Bookmark;
use JaysonTemporas\PageBookmarks\Models\BookmarkFolder;
trait HasBookmarks
{
/**
* Get all bookmarks for the user.
*/
public function bookmarks(): HasMany
{
return $this->hasMany(Bookmark::class);
}
/**
* Get all bookmark folders for the user.
*/
public function bookmarkFolders(): HasMany
{
return $this->hasMany(BookmarkFolder::class);
}
/**
* Get bookmarks that are not in any folder (root level bookmarks).
*/
public function rootBookmarks(): HasMany
{
return $this->bookmarks()->whereNull('bookmark_folder_id');
}
/**
* Create a new bookmark folder for the user.
*/
public function createBookmarkFolder(array $attributes = []): BookmarkFolder
{
return $this->bookmarkFolders()->create($attributes);
}
/**
* Create a new bookmark for the user.
*/
public function createBookmark(array $attributes = []): Bookmark
{
return $this->bookmarks()->create($attributes);
}
/**
* Get bookmarks in a specific folder.
*/
public function bookmarksInFolder(BookmarkFolder $folder): HasMany
{
return $this->bookmarks()->where('bookmark_folder_id', $folder->id);
}
/**
* Get bookmarks in a specific folder by folder ID.
*/
public function bookmarksInFolderById(int $folderId): HasMany
{
return $this->bookmarks()->where('bookmark_folder_id', $folderId);
}
/**
* Check if user has any bookmarks.
*/
public function hasBookmarks(): bool
{
return $this->bookmarks()->exists();
}
/**
* Check if user has any bookmark folders.
*/
public function hasBookmarkFolders(): bool
{
return $this->bookmarkFolders()->exists();
}
/**
* Get the total count of bookmarks for the user.
*/
public function getBookmarksCount(): int
{
return $this->bookmarks()->count();
}
/**
* Get the total count of bookmark folders for the user.
*/
public function getBookmarkFoldersCount(): int
{
return $this->bookmarkFolders()->count();
}
}