diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml index 1b99f85..f851aa5 100644 --- a/.github/workflows/fix-php-code-style-issues.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.head_ref }} diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 3855a08..44eeada 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -11,12 +11,12 @@ jobs: name: phpstan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7c60f1f..04cc32c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,19 +13,19 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.2, 8.1] - laravel: [10.*] + php: [8.2, 8.3] + laravel: [11.*] stability: [prefer-lowest, prefer-stable] include: - - laravel: 10.* - testbench: 8.* + - laravel: 11.* + testbench: 9.* carbon: 2.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index 859924d..700d480 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: main diff --git a/composer.json b/composer.json index bb6be65..ac97192 100644 --- a/composer.json +++ b/composer.json @@ -1,36 +1,39 @@ { - "name": ":vendor_slug/:package_slug", - "description": ":package_description", + "name": "tapp/filament-library", + "description": "A Google Drive-like file management system for Filament", "keywords": [ - ":vendor_name", + "tapp", "laravel", - ":package_slug" + "filament", + "library", + "file-management" ], - "homepage": "https://github.com/:vendor_slug/:package_slug", + "homepage": "https://github.com/TappNetwork/Filament-Library", "support": { - "issues": "https://github.com/:vendor_slug/:package_slug/issues", - "source": "https://github.com/:vendor_slug/:package_slug" + "issues": "https://github.com/TappNetwork/Filament-Library/issues", + "source": "https://github.com/TappNetwork/Filament-Library" }, "license": "MIT", "authors": [ { - "name": ":author_name", - "email": "author@domain.com", + "name": "Tapp Network", + "email": "dev@tappnetwork.com", "role": "Developer" } ], "require": { "php": "^8.1", - "filament/filament": "^3.0", - "filament/forms": "^3.0", - "filament/tables": "^3.0", - "spatie/laravel-package-tools": "^1.15.0" + "filament/filament": "^4.0", + "illuminate/contracts": "^10.0|^11.0", + "spatie/laravel-package-tools": "^1.15.0", + "spatie/laravel-medialibrary": "^11.0", + "filament/spatie-laravel-media-library-plugin": "^4.0" }, "require-dev": { "laravel/pint": "^1.0", - "nunomaduro/collision": "^7.9", + "nunomaduro/collision": "^8.0", "nunomaduro/larastan": "^2.0.1", - "orchestra/testbench": "^8.0", + "orchestra/testbench": "^9.0", "pestphp/pest": "^2.1", "pestphp/pest-plugin-arch": "^2.0", "pestphp/pest-plugin-laravel": "^2.0", @@ -41,13 +44,13 @@ }, "autoload": { "psr-4": { - "VendorName\\Skeleton\\": "src/", - "VendorName\\Skeleton\\Database\\Factories\\": "database/factories/" + "Tapp\\FilamentLibrary\\": "src/", + "Tapp\\FilamentLibrary\\Database\\Factories\\": "database/factories/" } }, "autoload-dev": { "psr-4": { - "VendorName\\Skeleton\\Tests\\": "tests/" + "Tapp\\FilamentLibrary\\Tests\\": "tests/" } }, "scripts": { @@ -67,10 +70,10 @@ "extra": { "laravel": { "providers": [ - "VendorName\\Skeleton\\SkeletonServiceProvider" + "Tapp\\FilamentLibrary\\FilamentLibraryServiceProvider" ], "aliases": { - "Skeleton": "VendorName\\Skeleton\\Facades\\Skeleton" + "FilamentLibrary": "Tapp\\FilamentLibrary\\Facades\\FilamentLibrary" } } }, diff --git a/config/filament-library.php b/config/filament-library.php new file mode 100644 index 0000000..e723067 --- /dev/null +++ b/config/filament-library.php @@ -0,0 +1,128 @@ + 5, + + /* + |-------------------------------------------------------------------------- + | Soft Delete Days + |-------------------------------------------------------------------------- + | + | Number of days to keep soft-deleted items before permanent deletion. + | Set to null to disable automatic cleanup. + | + */ + 'soft_delete_days' => 30, + + /* + |-------------------------------------------------------------------------- + | Table Columns + |-------------------------------------------------------------------------- + | + | Configuration for which columns to show in the library table. + | + */ + 'table_columns' => [ + 'name' => true, + 'type' => true, + 'size' => true, + 'created_at' => true, + 'updated_at' => false, + ], + + /* + |-------------------------------------------------------------------------- + | File Operations + |-------------------------------------------------------------------------- + | + | Configuration for file upload and handling. + | + */ + 'file_operations' => [ + 'allowed_mime_types' => [ + 'image/*', + 'application/pdf', + 'text/*', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip', + 'application/x-rar-compressed', + ], + 'max_file_size' => 50 * 1024 * 1024, // 50MB default + ], + + /* + |-------------------------------------------------------------------------- + | Media Library Configuration + |-------------------------------------------------------------------------- + | + | Configuration for Spatie Media Library integration. + | Most settings are handled by the Media Library's own configuration. + | + */ + 'media_library' => [ + 'collection_name' => 'files', + 'conversion_name' => 'thumb', + 'disk' => null, // Use Media Library's default disk + ], + + /* + |-------------------------------------------------------------------------- + | Permission Configuration + |-------------------------------------------------------------------------- + | + | Configuration for permission handling and caching. + | + */ + 'permissions' => [ + 'cache_ttl' => 3600, // 1 hour + 'auto_inherit' => true, // Automatically inherit parent permissions + 'cascade_on_change' => true, // Cascade permission changes to children + ], + + /* + |-------------------------------------------------------------------------- + | User Model Configuration + |-------------------------------------------------------------------------- + | + | Configuration for user model integration. + | + */ + 'user_model' => \App\Models\User::class, + + /* + |-------------------------------------------------------------------------- + | Navigation Configuration + |-------------------------------------------------------------------------- + | + | Configuration for Filament navigation integration. + | + */ + 'navigation' => [ + 'group' => 'Library', + 'icon' => 'heroicon-o-folder', + 'sort' => 10, + ], +]; diff --git a/config/skeleton.php b/config/skeleton.php deleted file mode 100644 index 7e74186..0000000 --- a/config/skeleton.php +++ /dev/null @@ -1,6 +0,0 @@ -id(); + $table->string('name'); + $table->string('slug'); + $table->enum('type', ['folder', 'file']); + $table->foreignId('parent_id')->nullable()->constrained('library_items')->cascadeOnDelete(); + $table->foreignId('created_by')->constrained('users'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['parent_id', 'type']); + $table->unique(['parent_id', 'slug'], 'lib_items_parent_slug_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('library_items'); + } +}; diff --git a/database/migrations/2024_01_01_000001_create_library_item_permissions_table.php b/database/migrations/2024_01_01_000001_create_library_item_permissions_table.php new file mode 100644 index 0000000..b4ea658 --- /dev/null +++ b/database/migrations/2024_01_01_000001_create_library_item_permissions_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('library_item_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('permission', ['view', 'edit']); + $table->timestamps(); + + $table->unique(['library_item_id', 'user_id', 'permission'], 'lib_item_perms_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('library_item_permissions'); + } +}; diff --git a/database/migrations/create_skeleton_table.php.stub b/database/migrations/create_skeleton_table.php.stub deleted file mode 100644 index 2efdce9..0000000 --- a/database/migrations/create_skeleton_table.php.stub +++ /dev/null @@ -1,19 +0,0 @@ -id(); - - // add fields - - $table->timestamps(); - }); - } -}; diff --git a/database/seeders/LibrarySeeder.php b/database/seeders/LibrarySeeder.php new file mode 100644 index 0000000..5c37ac2 --- /dev/null +++ b/database/seeders/LibrarySeeder.php @@ -0,0 +1,159 @@ +command->warn('No users found. Please create a user first.'); + + return; + } + + $this->command->info('Creating sample library structure...'); + + // Create root folders + $documents = LibraryItem::create([ + 'name' => 'Documents', + 'type' => 'folder', + 'created_by' => $user->id, + ]); + + $images = LibraryItem::create([ + 'name' => 'Images', + 'type' => 'folder', + 'created_by' => $user->id, + ]); + + // Create subfolders under Documents + $projects = LibraryItem::create([ + 'name' => 'Projects', + 'type' => 'folder', + 'parent_id' => $documents->id, + 'created_by' => $user->id, + ]); + + $templates = LibraryItem::create([ + 'name' => 'Templates', + 'type' => 'folder', + 'parent_id' => $documents->id, + 'created_by' => $user->id, + ]); + + // Create project subfolders + $projectA = LibraryItem::create([ + 'name' => 'Project A', + 'type' => 'folder', + 'parent_id' => $projects->id, + 'created_by' => $user->id, + ]); + + $projectB = LibraryItem::create([ + 'name' => 'Project B', + 'type' => 'folder', + 'parent_id' => $projects->id, + 'created_by' => $user->id, + ]); + + // Create subfolders under Images + $photos = LibraryItem::create([ + 'name' => 'Photos', + 'type' => 'folder', + 'parent_id' => $images->id, + 'created_by' => $user->id, + ]); + + $graphics = LibraryItem::create([ + 'name' => 'Graphics', + 'type' => 'folder', + 'parent_id' => $images->id, + 'created_by' => $user->id, + ]); + + // Create some sample files (without actual media attachments for now) + $sampleFiles = [ + [ + 'name' => 'README.md', + 'parent_id' => $projectA->id, + ], + [ + 'name' => 'requirements.txt', + 'parent_id' => $projectA->id, + ], + [ + 'name' => 'Documentation.pdf', + 'parent_id' => $projectB->id, + ], + [ + 'name' => 'Project Brief.docx', + 'parent_id' => $templates->id, + ], + [ + 'name' => 'Meeting Notes.txt', + 'parent_id' => $documents->id, + ], + ]; + + foreach ($sampleFiles as $fileData) { + LibraryItem::create([ + 'name' => $fileData['name'], + 'type' => 'file', + 'parent_id' => $fileData['parent_id'], + 'created_by' => $user->id, + ]); + } + + // Create some sample permissions if there are other users + $otherUsers = \App\Models\User::where('id', '!=', $user->id)->take(2)->get(); + + if ($otherUsers->count() > 0) { + $this->command->info('Creating sample permissions...'); + + // Give first other user view access to Documents folder + if ($otherUsers->count() >= 1) { + LibraryItemPermission::create([ + 'library_item_id' => $documents->id, + 'user_id' => $otherUsers[0]->id, + 'permission' => 'view', + ]); + } + + // Give second other user edit access to Project A + if ($otherUsers->count() >= 2) { + LibraryItemPermission::create([ + 'library_item_id' => $projectA->id, + 'user_id' => $otherUsers[1]->id, + 'permission' => 'edit', + ]); + } + } + + $this->command->info('Sample library structure created successfully!'); + $this->command->info('Created:'); + $this->command->info('- Documents/'); + $this->command->info(' - Projects/'); + $this->command->info(' - Project A/ (with 2 files)'); + $this->command->info(' - Project B/ (with 1 file)'); + $this->command->info(' - Templates/ (with 1 file)'); + $this->command->info(' - Meeting Notes.txt'); + $this->command->info('- Images/'); + $this->command->info(' - Photos/'); + $this->command->info(' - Graphics/'); + + if ($otherUsers->count() > 0) { + $this->command->info('Sample permissions created for other users.'); + } + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a91953b..e37b34d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,4 +11,12 @@ parameters: checkOctaneCompatibility: true checkModelProperties: true checkMissingIterableValueType: false + ignoreErrors: + - '#Class App\\Models\\User not found#' + - '#Call to static method .* on an unknown class App\\Models\\User#' + - '#Access to an undefined property .*\$type#' + - '#Access to an undefined property .*\$parent_id#' + - '#Access to an undefined property .*\$name#' + - '#Access to an undefined property .*\$parent#' + - '#Access to an undefined property .*\$slug#' diff --git a/resources/lang/en/skeleton.php b/resources/lang/en/skeleton.php deleted file mode 100644 index d1b24d9..0000000 --- a/resources/lang/en/skeleton.php +++ /dev/null @@ -1,6 +0,0 @@ -info('Filament Library Plugin'); + $this->line('A Google Drive-like file management system for Filament'); + $this->line(''); + $this->line('Version: 1.0.0'); + $this->line('Author: Tapp Network'); + $this->line('Repository: https://github.com/TappNetwork/Filament-Library'); + + return self::SUCCESS; + } +} diff --git a/src/Commands/SkeletonCommand.php b/src/Commands/SkeletonCommand.php deleted file mode 100644 index 3e5f628..0000000 --- a/src/Commands/SkeletonCommand.php +++ /dev/null @@ -1,19 +0,0 @@ -comment('All done'); - - return self::SUCCESS; - } -} diff --git a/src/Facades/FilamentLibrary.php b/src/Facades/FilamentLibrary.php new file mode 100644 index 0000000..b8c4f02 --- /dev/null +++ b/src/Facades/FilamentLibrary.php @@ -0,0 +1,16 @@ +resources([ + \Tapp\FilamentLibrary\Resources\LibraryItemResource::class, + ]); } public function boot(Panel $panel): void { - // + // Boot any services, register listeners, etc. } public static function make(): static diff --git a/src/SkeletonServiceProvider.php b/src/FilamentLibraryServiceProvider.php similarity index 66% rename from src/SkeletonServiceProvider.php rename to src/FilamentLibraryServiceProvider.php index 6caab3f..dd752b7 100644 --- a/src/SkeletonServiceProvider.php +++ b/src/FilamentLibraryServiceProvider.php @@ -1,26 +1,23 @@ publishConfigFile() ->publishMigrations() ->askToRunMigrations() - ->askToStarRepoOnGitHub(':vendor_slug/:package_slug'); + ->askToStarRepoOnGitHub('TappNetwork/Filament-Library'); }); $configFileName = $package->shortName(); @@ -80,18 +77,17 @@ public function packageBooted(): void if (app()->runningInConsole()) { foreach (app(Filesystem::class)->files(__DIR__ . '/../stubs/') as $file) { $this->publishes([ - $file->getRealPath() => base_path("stubs/skeleton/{$file->getFilename()}"), - ], 'skeleton-stubs'); + $file->getRealPath() => base_path("stubs/filament-library/{$file->getFilename()}"), + ], 'filament-library-stubs'); } } - // Testing - Testable::mixin(new TestsSkeleton); + // Testing - No custom test mixins needed } protected function getAssetPackageName(): ?string { - return ':vendor_slug/:package_slug'; + return 'tapp/filament-library'; } /** @@ -99,11 +95,23 @@ protected function getAssetPackageName(): ?string */ protected function getAssets(): array { - return [ - // AlpineComponent::make('skeleton', __DIR__ . '/../resources/dist/components/skeleton.js'), - Css::make('skeleton-styles', __DIR__ . '/../resources/dist/skeleton.css'), - Js::make('skeleton-scripts', __DIR__ . '/../resources/dist/skeleton.js'), - ]; + $assets = []; + + // Register our custom CSS file + if (file_exists(__DIR__ . '/../resources/css/filament-library.css')) { + $assets[] = Css::make('filament-library-styles', __DIR__ . '/../resources/css/filament-library.css'); + } + + // Only register dist assets if they exist + if (file_exists(__DIR__ . '/../resources/dist/filament-library.css')) { + $assets[] = Css::make('filament-library-dist-styles', __DIR__ . '/../resources/dist/filament-library.css'); + } + + if (file_exists(__DIR__ . '/../resources/dist/filament-library.js')) { + $assets[] = Js::make('filament-library-scripts', __DIR__ . '/../resources/dist/filament-library.js'); + } + + return $assets; } /** @@ -112,7 +120,7 @@ protected function getAssets(): array protected function getCommands(): array { return [ - SkeletonCommand::class, + FilamentLibraryCommand::class, ]; } @@ -146,7 +154,8 @@ protected function getScriptData(): array protected function getMigrations(): array { return [ - 'create_skeleton_table', + '2024_01_01_000000_create_library_items_table', + '2024_01_01_000001_create_library_item_permissions_table', ]; } } diff --git a/src/Models/LibraryItem.php b/src/Models/LibraryItem.php new file mode 100644 index 0000000..105b670 --- /dev/null +++ b/src/Models/LibraryItem.php @@ -0,0 +1,233 @@ + 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * Boot the model. + */ + protected static function boot(): void + { + parent::boot(); + + static::creating(function (self $item) { + if (empty($item->slug)) { + $item->slug = static::generateUniqueSlug($item->name, $item->parent_id); + } + }); + + static::updating(function (self $item) { + if ($item->isDirty('name') && ! $item->isDirty('slug')) { + $item->slug = static::generateUniqueSlug($item->name, $item->parent_id, $item->id); + } + }); + } + + /** + * Get the parent folder. + */ + public function parent(): BelongsTo + { + return $this->belongsTo(LibraryItem::class, 'parent_id'); + } + + /** + * Get the child items. + */ + public function children(): HasMany + { + return $this->hasMany(LibraryItem::class, 'parent_id'); + } + + /** + * Get the user who created this item. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + /** + * Get the user who last updated this item. + */ + public function updater(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'updated_by'); + } + + /** + * Get the permissions for this item. + */ + public function permissions(): HasMany + { + return $this->hasMany(LibraryItemPermission::class); + } + + /** + * Scope to get only folders. + */ + public function scopeFolders($query) + { + return $query->where('type', 'folder'); + } + + /** + * Scope to get only files. + */ + public function scopeFiles($query) + { + return $query->where('type', 'file'); + } + + /** + * Scope to get items accessible by a user. + */ + public function scopeForUser($query, $user) + { + return $query->where(function ($q) use ($user) { + $q->where('created_by', $user->id) + ->orWhereHas('permissions', function ($permissionQuery) use ($user) { + $permissionQuery->where('user_id', $user->id); + }); + }); + } + + /** + * Check if a user has a specific permission on this item. + */ + public function hasPermission($user, string $permission): bool + { + // Check direct permissions + $directPermission = $this->permissions() + ->where('user_id', $user->id) + ->where('permission', $permission) + ->exists(); + + if ($directPermission) { + return true; + } + + // Check inherited permissions from parent folders + if ($this->parent_id) { + return $this->parent->hasPermission($user, $permission); + } + + return false; + } + + /** + * Get the full path of this item. + */ + public function getPath(): string + { + $path = collect([$this->name]); + $parent = $this->parent; + + while ($parent) { + $path->prepend($parent->name); + $parent = $parent->parent; + } + + return $path->implode('/'); + } + + /** + * Get the file size for files. + */ + public function getSizeAttribute(): ?int + { + if ($this->type !== 'file') { + return null; + } + + $media = $this->getFirstMedia('files'); + + return $media ? $media->size : null; + } + + /** + * Register media collections. + */ + public function registerMediaCollections(): void + { + $this->addMediaCollection('files') + ->acceptsMimeTypes([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ]) + ->singleFile(); + } + + /** + * Register media conversions. + */ + public function registerMediaConversions(?Media $media = null): void + { + $this->addMediaConversion('thumb') + ->width(300) + ->height(300); + } + + /** + * Generate a unique slug for the given name and parent. + */ + protected static function generateUniqueSlug(string $name, ?int $parentId = null, ?int $excludeId = null): string + { + $baseSlug = Str::slug($name); + $slug = $baseSlug; + $counter = 1; + + // Check for existing slugs (including soft-deleted ones) + while (static::withTrashed() + ->where('slug', $slug) + ->where('parent_id', $parentId) + ->when($excludeId, fn ($q) => $q->where('id', '!=', $excludeId)) + ->exists()) { + $slug = $baseSlug . '-' . $counter; + $counter++; + } + + return $slug; + } +} diff --git a/src/Models/LibraryItemPermission.php b/src/Models/LibraryItemPermission.php new file mode 100644 index 0000000..3068f91 --- /dev/null +++ b/src/Models/LibraryItemPermission.php @@ -0,0 +1,39 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * Get the library item this permission belongs to. + */ + public function libraryItem(): BelongsTo + { + return $this->belongsTo(LibraryItem::class); + } + + /** + * Get the user this permission belongs to. + */ + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class); + } +} diff --git a/src/Resources/LibraryItemResource.php b/src/Resources/LibraryItemResource.php new file mode 100644 index 0000000..461bda9 --- /dev/null +++ b/src/Resources/LibraryItemResource.php @@ -0,0 +1,221 @@ +schema([ + \Filament\Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + ]); + } + + public static function folderForm(Schema $schema): Schema + { + return $schema + ->schema([ + \Filament\Forms\Components\TextInput::make('name') + ->label('Folder Name') + ->required() + ->maxLength(255) + ->placeholder('Enter folder name'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable() + ->icon( + fn (LibraryItem $record): string => $record->type === 'folder' ? 'heroicon-s-folder' : 'heroicon-o-document' + ) + ->iconPosition('before'), + Tables\Columns\TextColumn::make('updater.name') + ->label('Modified By') + ->searchable() + ->sortable() + ->toggleable(), + Tables\Columns\TextColumn::make('updated_at') + ->label('Modified At') + ->dateTime() + ->sortable() + ->toggleable(), + Tables\Columns\TextColumn::make('creator.name') + ->label('Created By') + ->searchable() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('created_at') + ->label('Created At') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('type') + ->options([ + 'folder' => 'Folder', + 'file' => 'File', + ]), + Tables\Filters\TrashedFilter::make(), + ]) + ->recordActions([ + ActionGroup::make([ + Action::make('view') + ->label('View') + ->icon('heroicon-o-eye') + ->url(function (LibraryItem $record): string { + // Use the same logic as recordUrl - cache URL generation to reduce computation + $cacheKey = 'record_url_' . $record->id . '_' . $record->type; + + return cache()->remember($cacheKey, 60, function () use ($record) { // 1 minute cache + return $record->type === 'folder' + ? static::getUrl('index', ['parent' => $record->id]) + : static::getUrl('view', ['record' => $record]); + }); + }), + EditAction::make() + ->color('gray'), + DeleteAction::make() + ->color('gray') + ->before(function (LibraryItem $record) { + // Store parent_id before deletion + static::$deletedParentId = $record->parent_id; + }) + ->successRedirectUrl(function () { + // Redirect to the parent folder after deletion + $parentId = static::$deletedParentId; + + return static::getUrl('index', $parentId ? ['parent' => $parentId] : []); + }), + RestoreAction::make(), + ForceDeleteAction::make() + ->before(function (LibraryItem $record) { + // Store parent_id before deletion + static::$deletedParentId = $record->parent_id; + }) + ->successRedirectUrl(function () { + // Redirect to the parent folder after deletion + $parentId = static::$deletedParentId; + + return static::getUrl('index', $parentId ? ['parent' => $parentId] : []); + }), + ]) + ->icon('heroicon-m-ellipsis-vertical') + ->color('gray') + ->iconButton(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make() + ->successRedirectUrl(function () { + // For bulk actions, redirect to current folder (maintain current location) + $currentParent = request()->get('parent'); + + return static::getUrl('index', $currentParent ? ['parent' => $currentParent] : []); + }), + RestoreBulkAction::make(), + ForceDeleteBulkAction::make() + ->successRedirectUrl(function () { + // For bulk actions, redirect to current folder (maintain current location) + $currentParent = request()->get('parent'); + + return static::getUrl('index', $currentParent ? ['parent' => $currentParent] : []); + }), + ]), + ]) + ->recordUrl(function (LibraryItem $record): string { + // Cache URL generation to reduce computation during rapid navigation + $cacheKey = 'record_url_' . $record->id . '_' . $record->type; + + return cache()->remember($cacheKey, 60, function () use ($record) { // 1 minute cache + return $record->type === 'folder' + ? static::getUrl('index', ['parent' => $record->id]) + : static::getUrl('view', ['record' => $record]); + }); + }); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLibraryItems::route('/'), + 'create-folder' => Pages\CreateFolder::route('/create-folder'), + 'create-file' => Pages\CreateFile::route('/create-file'), + 'view' => Pages\ViewLibraryItem::route('/{record}'), + 'edit' => Pages\EditLibraryItem::route('/{record}/edit'), + ]; + } +} diff --git a/src/Resources/Pages/CreateFile.php b/src/Resources/Pages/CreateFile.php new file mode 100644 index 0000000..cf27940 --- /dev/null +++ b/src/Resources/Pages/CreateFile.php @@ -0,0 +1,23 @@ +getParentId(); + $data['created_by'] = auth()->id(); + + return $data; + } +} diff --git a/src/Resources/Pages/CreateFolder.php b/src/Resources/Pages/CreateFolder.php new file mode 100644 index 0000000..1772ecc --- /dev/null +++ b/src/Resources/Pages/CreateFolder.php @@ -0,0 +1,23 @@ +getParentId(); + $data['created_by'] = auth()->id(); + + return $data; + } +} diff --git a/src/Resources/Pages/EditLibraryItem.php b/src/Resources/Pages/EditLibraryItem.php new file mode 100644 index 0000000..3beb462 --- /dev/null +++ b/src/Resources/Pages/EditLibraryItem.php @@ -0,0 +1,118 @@ +getRecord(); + $type = $record->type === 'folder' ? 'Folder' : 'File'; + + return "Edit {$type}"; + } + + protected function getHeaderActions(): array + { + $actions = []; + + // Add "View Folder" action if we have a parent + if ($this->getRecord()->parent_id) { + $actions[] = Action::make('view_folder') + ->label('View Folder') + ->icon('heroicon-o-arrow-up') + ->color('gray') + ->url( + fn (): string => static::getResource()::getUrl('index', ['parent' => $this->getRecord()->parent_id]) + ); + } + + $actions[] = DeleteAction::make() + ->before(function () { + // Store parent_id before deletion + $this->parentId = $this->getRecord()->parent_id; + }) + ->successRedirectUrl(function () { + // Redirect to the parent folder after deletion + $parentId = $this->parentId; + + return static::getResource()::getUrl('index', $parentId ? ['parent' => $parentId] : []); + }); + + return $actions; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + // Remove fields that shouldn't be editable + unset($data['type']); + unset($data['parent_id']); + unset($data['created_by']); + + return $data; + } + + protected function mutateFormDataBeforeSave(array $data): array + { + // Set the updated_by field + $data['updated_by'] = auth()->user()?->id; + + return $data; + } + + protected function getForms(): array + { + return [ + 'form' => $this->form(static::getResource()::form( + \Filament\Schemas\Schema::make() + )) + ->statePath('data') + ->model($this->getRecord()), + ]; + } + + public function getBreadcrumbs(): array + { + $breadcrumbs = [ + static::getResource()::getUrl() => 'All Folders', + ]; + + $record = $this->getRecord(); + + if ($record->parent_id) { + // Cache the breadcrumb path to avoid repeated computation + $cacheKey = 'breadcrumbs_' . $record->parent_id; + $path = cache()->remember($cacheKey, 300, function () use ($record) { // 5 minute cache + $current = $record->parent; + $path = []; + + while ($current) { + array_unshift($path, $current); + $current = $current->parent; + } + + return $path; + }); + + // Generate URLs more efficiently + $baseUrl = static::getResource()::getUrl('index'); + foreach ($path as $folder) { + $breadcrumbs[$baseUrl . '?parent=' . $folder->id] = $folder->name; + } + } + + // Add current item to breadcrumbs + $breadcrumbs[] = $record->name; + + return $breadcrumbs; + } +} diff --git a/src/Resources/Pages/ListLibraryItems.php b/src/Resources/Pages/ListLibraryItems.php new file mode 100644 index 0000000..84e83fc --- /dev/null +++ b/src/Resources/Pages/ListLibraryItems.php @@ -0,0 +1,171 @@ +parentId = request()->get('parent'); + + if ($this->parentId) { + $this->parentFolder = LibraryItem::find($this->parentId); + } + } + + protected function getHeaderActions(): array + { + $actions = []; + + // Add "Up One Level" action if we're in a subfolder + if ($this->parentId && $this->parentFolder) { + $actions[] = Action::make('up_one_level') + ->label('Up One Level') + ->icon('heroicon-o-arrow-up') + ->url( + fn (): string => $this->parentFolder->parent_id + ? static::getResource()::getUrl('index', ['parent' => $this->parentFolder->parent_id]) + : static::getResource()::getUrl('index') + ) + ->color('gray'); + + // Add "Edit" action for the current folder + $actions[] = Action::make('edit_folder') + ->label('Edit') + ->icon('heroicon-o-pencil') + ->color('gray') + ->url( + fn (): string => static::getResource()::getUrl('edit', ['record' => $this->parentFolder]) + ); + } + + // Add "+ New" dropdown action group + $actions[] = ActionGroup::make([ + Action::make('create_folder') + ->label('Create Folder') + ->icon('heroicon-o-folder-plus') + ->schema([ + TextInput::make('name') + ->label('Folder Name') + ->required() + ->maxLength(255) + ->placeholder('Enter folder name'), + ]) + ->action(function (array $data): void { + LibraryItem::create([ + 'name' => $data['name'], + 'type' => 'folder', + 'parent_id' => $this->parentId, + 'created_by' => auth()->user()?->id, + 'updated_by' => auth()->user()?->id, + ]); + + $this->redirect(static::getResource()::getUrl('index', $this->parentId ? ['parent' => $this->parentId] : [])); + }), + Action::make('upload_file') + ->label('Upload File') + ->icon('heroicon-o-document-plus') + ->schema([ + FileUpload::make('file') + ->label('Upload File') + ->required() + ->maxSize(10240) // 10MB + ->disk('public') + ->directory('library-files') + ->visibility('private') + ->preserveFilenames(), // This should preserve original filenames + ]) + ->action(function (array $data): void { + $filePath = $data['file']; + + // Extract filename from the stored path - this should preserve the original name + $fileName = basename($filePath); + + LibraryItem::create([ + 'name' => $fileName, + 'type' => 'file', + 'parent_id' => $this->parentId, + 'created_by' => auth()->user()?->id, + 'updated_by' => auth()->user()?->id, + ]); + + $this->redirect(static::getResource()::getUrl('index', $this->parentId ? ['parent' => $this->parentId] : [])); + }), + ]) + ->label('New') + ->icon('heroicon-o-plus') + ->color('primary') + ->button(); + + return $actions; + } + + protected function getTableQuery(): \Illuminate\Database\Eloquent\Builder + { + $query = parent::getTableQuery(); + + if ($this->parentId) { + $query->where('parent_id', $this->parentId); + } else { + $query->whereNull('parent_id'); + } + + return $query; + } + + public function getTitle(): string + { + if ($this->parentFolder) { + return $this->parentFolder->name; + } + + return 'All Folders'; + } + + public function getBreadcrumbs(): array + { + $breadcrumbs = [ + static::getResource()::getUrl() => 'All Folders', + ]; + + if ($this->parentFolder) { + // Cache the breadcrumb path to avoid repeated computation + $cacheKey = 'breadcrumbs_' . $this->parentFolder->id; + $path = cache()->remember($cacheKey, 300, function () { // 5 minute cache + $current = $this->parentFolder; + $path = []; + + while ($current) { + array_unshift($path, $current); + $current = $current->parent; + } + + return $path; + }); + + // Generate URLs more efficiently + $baseUrl = static::getResource()::getUrl('index'); + foreach ($path as $folder) { + $breadcrumbs[$baseUrl . '?parent=' . $folder->id] = $folder->name; + } + } + + return $breadcrumbs; + } +} diff --git a/src/Resources/Pages/ViewLibraryItem.php b/src/Resources/Pages/ViewLibraryItem.php new file mode 100644 index 0000000..320e15c --- /dev/null +++ b/src/Resources/Pages/ViewLibraryItem.php @@ -0,0 +1,89 @@ +getRecord(); + $type = $record->type === 'folder' ? 'Folder' : 'File'; + + return "View {$type}"; + } + + protected function getHeaderActions(): array + { + $actions = []; + + // Add "View Folder" action if we have a parent + if ($this->getRecord()->parent_id) { + $actions[] = Action::make('view_folder') + ->label('View Folder') + ->icon('heroicon-o-arrow-up') + ->color('gray') + ->url( + fn (): string => static::getResource()::getUrl('index', ['parent' => $this->getRecord()->parent_id]) + ); + } + + $actions[] = \Filament\Actions\EditAction::make(); + $actions[] = \Filament\Actions\DeleteAction::make() + ->before(function () { + // Store parent_id before deletion + $this->parentId = $this->getRecord()->parent_id; + }) + ->successRedirectUrl(function () { + // Redirect to the parent folder after deletion + $parentId = $this->parentId; + + return static::getResource()::getUrl('index', $parentId ? ['parent' => $parentId] : []); + }); + + return $actions; + } + + public function getBreadcrumbs(): array + { + $breadcrumbs = [ + static::getResource()::getUrl() => 'All Folders', + ]; + + $record = $this->getRecord(); + + if ($record->parent_id) { + // Cache the breadcrumb path to avoid repeated computation + $cacheKey = 'breadcrumbs_' . $record->parent_id; + $path = cache()->remember($cacheKey, 300, function () use ($record) { // 5 minute cache + $current = $record->parent; + $path = []; + + while ($current) { + array_unshift($path, $current); + $current = $current->parent; + } + + return $path; + }); + + // Generate URLs more efficiently + $baseUrl = static::getResource()::getUrl('index'); + foreach ($path as $folder) { + $breadcrumbs[$baseUrl . '?parent=' . $folder->id] = $folder->name; + } + } + + // Add current item to breadcrumbs + $breadcrumbs[] = $record->name; + + return $breadcrumbs; + } +} diff --git a/src/Services/PermissionService.php b/src/Services/PermissionService.php new file mode 100644 index 0000000..02f0c92 --- /dev/null +++ b/src/Services/PermissionService.php @@ -0,0 +1,209 @@ +getCacheKey($user->id, $item->id, $permission); + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($user, $item, $permission) { + return $this->checkPermissionRecursive($user, $item, $permission); + }); + } + + /** + * Assign permissions to a user for an item. + */ + public function assignPermission($user, LibraryItem $item, string $permission): void + { + LibraryItemPermission::updateOrCreate( + [ + 'library_item_id' => $item->id, + 'user_id' => $user->id, + 'permission' => $permission, + ], + [ + 'library_item_id' => $item->id, + 'user_id' => $user->id, + 'permission' => $permission, + ] + ); + + $this->clearPermissionCache($user->id, $item->id); + } + + /** + * Remove permissions from a user for an item. + */ + public function removePermission($user, LibraryItem $item, string $permission): void + { + LibraryItemPermission::where([ + 'library_item_id' => $item->id, + 'user_id' => $user->id, + 'permission' => $permission, + ])->delete(); + + $this->clearPermissionCache($user->id, $item->id); + } + + /** + * Bulk assign permissions to multiple users for multiple items. + */ + public function bulkAssignPermissions(Collection $items, array $data): void + { + $userIds = $data['user_ids'] ?? []; + $permission = $data['permission'] ?? 'view'; + $cascadeToChildren = $data['cascade_to_children'] ?? false; + + foreach ($items as $item) { + foreach ($userIds as $userId) { + $this->assignPermission( + \App\Models\User::find($userId), + $item, + $permission + ); + } + + // Cascade permissions to children if requested + if ($cascadeToChildren && $item->type === 'folder') { + $this->cascadePermissionsToChildren($item, $userIds, $permission); + } + } + } + + /** + * Cascade permissions from a folder to all its children. + */ + public function cascadePermissionsToChildren(LibraryItem $folder, array $userIds, string $permission): void + { + $children = $folder->children; + + foreach ($children as $child) { + foreach ($userIds as $userId) { + $this->assignPermission( + \App\Models\User::find($userId), + $child, + $permission + ); + } + + // Recursively cascade to grandchildren + if ($child->type === 'folder') { + $this->cascadePermissionsToChildren($child, $userIds, $permission); + } + } + } + + /** + * Get all users who have permissions on an item. + */ + public function getUsersWithPermissions(LibraryItem $item): \Illuminate\Support\Collection + { + return $item->permissions() + ->with('user') + ->get() + ->pluck('user') + ->unique('id'); + } + + /** + * Get all permissions for a user on an item. + */ + public function getUserPermissions($user, LibraryItem $item): array + { + $permissions = []; + + // Check direct permissions + $directPermissions = $item->permissions() + ->where('user_id', $user->id) + ->pluck('permission') + ->toArray(); + + $permissions = array_merge($permissions, $directPermissions); + + // Check inherited permissions from parent folders + if ($item->parent_id) { + $inheritedPermissions = $this->getUserPermissions($user, $item->parent); + $permissions = array_merge($permissions, $inheritedPermissions); + } + + return array_unique($permissions); + } + + /** + * Clear permission cache for a user and item. + */ + public function clearPermissionCache(int $userId, int $itemId): void + { + $patterns = [ + self::CACHE_PREFIX . $userId . '_' . $itemId . '_*', + ]; + + foreach ($patterns as $pattern) { + // Note: This is a simplified cache clearing approach + // In production, you might want to use a more sophisticated cache tagging system + Cache::forget($pattern); + } + } + + /** + * Clear all permission cache. + */ + public function clearAllPermissionCache(): void + { + // Note: This is a simplified approach + // In production, you might want to use cache tags + Cache::flush(); + } + + /** + * Check permission recursively (without cache). + */ + private function checkPermissionRecursive($user, LibraryItem $item, string $permission): bool + { + // Check direct permissions + $directPermission = $item->permissions() + ->where('user_id', $user->id) + ->where('permission', $permission) + ->exists(); + + if ($directPermission) { + return true; + } + + // Check inherited permissions from parent folders + if ($item->parent_id) { + return $this->checkPermissionRecursive($user, $item->parent, $permission); + } + + return false; + } + + /** + * Generate cache key for permission check. + */ + private function getCacheKey(int $userId, int $itemId, string $permission): string + { + return self::CACHE_PREFIX . $userId . '_' . $itemId . '_' . $permission; + } +} diff --git a/src/Skeleton.php b/src/Skeleton.php deleted file mode 100644 index 34c7194..0000000 --- a/src/Skeleton.php +++ /dev/null @@ -1,5 +0,0 @@ -font('DM Sans') - ->primaryColor(Color::Amber) - ->secondaryColor(Color::Gray) - ->warningColor(Color::Amber) - ->dangerColor(Color::Rose) - ->successColor(Color::Green) - ->grayColor(Color::Gray) - ->theme('skeleton'); - } - - public function boot(Panel $panel): void - { - // - } -} diff --git a/src/Testing/TestsSkeleton.php b/src/Testing/TestsSkeleton.php deleted file mode 100644 index 0e33b51..0000000 --- a/src/Testing/TestsSkeleton.php +++ /dev/null @@ -1,13 +0,0 @@ -isLibraryAdmin()) { + return true; + } + + return $item->created_by === $this->id || $item->hasPermission($this, 'view'); + } + + /** + * Check if the user can edit a specific library item. + */ + public function canEditLibraryItem(LibraryItem $item): bool + { + // Default implementation: creator can edit their items, admin can edit all + if ($this->isLibraryAdmin()) { + return true; + } + + return $item->created_by === $this->id || $item->hasPermission($this, 'edit'); + } + + /** + * Check if the user can view root library items. + */ + public function canViewRootLibraryItems(): bool + { + // Default: only admin can see root items, can be overridden + return $this->isLibraryAdmin(); + } + + /** + * Check if the user is a library admin. + * + * Override this method to add role-based logic. + */ + public function isLibraryAdmin(): bool + { + // Default implementation - override this method to add role-based logic + // For example: return $this->hasRole('admin') || $this->hasRole('library-admin'); + return false; + } + + /** + * Get all library items this user can view. + */ + public function getAccessibleLibraryItems() + { + return LibraryItem::forUser($this)->get(); + } + + /** + * Get root library items this user can view. + */ + public function getAccessibleRootLibraryItems() + { + if (! $this->canViewRootLibraryItems()) { + return collect(); + } + + return LibraryItem::whereNull('parent_id')->forUser($this)->get(); + } +} diff --git a/src/Traits/HasParentFolder.php b/src/Traits/HasParentFolder.php new file mode 100644 index 0000000..f9f27ac --- /dev/null +++ b/src/Traits/HasParentFolder.php @@ -0,0 +1,27 @@ +parentId = request()->get('parent'); + } + + protected function getRedirectUrl(): string + { + return $this->parentId + ? static::getResource()::getUrl('index', ['parent' => $this->parentId]) + : static::getResource()::getUrl('index'); + } + + protected function getParentId(): ?int + { + return $this->parentId; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 7fe1500..5bca49f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,5 @@ in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 14a77a0..868c71b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,6 +1,6 @@ 'VendorName\\Skeleton\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + fn (string $modelName) => 'Tapp\\FilamentLibrary\\Database\\Factories\\' . class_basename($modelName) . 'Factory' ); } @@ -40,11 +47,12 @@ protected function getPackageProviders($app) FormsServiceProvider::class, InfolistsServiceProvider::class, LivewireServiceProvider::class, + MediaLibraryServiceProvider::class, NotificationsServiceProvider::class, SupportServiceProvider::class, TablesServiceProvider::class, WidgetsServiceProvider::class, - SkeletonServiceProvider::class, + FilamentLibraryServiceProvider::class, ]; } @@ -52,9 +60,11 @@ public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); - /* - $migration = include __DIR__.'/../database/migrations/create_skeleton_table.php.stub'; + // Run the library migrations + $migration = include __DIR__ . '/../database/migrations/2024_01_01_000000_create_library_items_table.php'; + $migration->up(); + + $migration = include __DIR__ . '/../database/migrations/2024_01_01_000001_create_library_item_permissions_table.php'; $migration->up(); - */ } }