diff --git a/.github/workflows/auto-fix-honeybadger.yml b/.github/workflows/auto-fix-honeybadger.yml new file mode 100644 index 0000000..e8acf1b --- /dev/null +++ b/.github/workflows/auto-fix-honeybadger.yml @@ -0,0 +1,49 @@ +name: Auto-fix HoneyBadger Exception + +on: + repository_dispatch: + types: [honeybadger-exception] + workflow_dispatch: + inputs: + id: + description: 'Exception ID' + required: true + class: + description: 'Exception class' + required: true + message: + description: 'Exception message' + required: true + backtrace: + description: 'Backtrace as JSON string' + required: true + environment: + description: 'Environment name' + required: false + default: 'production' + url: + description: 'Exception tracking URL' + required: false + default: '' + pr_reviewers: + description: 'PR reviewers (comma-separated usernames or team slugs)' + required: false + default: 'TappNetwork/developers' + +jobs: + call-reusable-workflow: + uses: TappNetwork/workflows/.github/workflows/auto-fix-honeybadger.yml@main + with: + id: ${{ github.event.client_payload.id || github.event.inputs.id }} + class: ${{ github.event.client_payload.class || github.event.inputs.class }} + message: ${{ github.event.client_payload.message || github.event.inputs.message }} + backtrace: ${{ github.event.client_payload.backtrace || github.event.inputs.backtrace }} + environment: ${{ github.event.client_payload.environment || github.event.inputs.environment }} + url: ${{ github.event.client_payload.url || github.event.inputs.url }} + pr_reviewers: ${{ github.event.client_payload.pr_reviewers || github.event.inputs.pr_reviewers }} + secrets: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + GH_PAT: ${{ secrets.GH_PAT }} + COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} + FILAMENT_ADVANCED_TABLES_USER: ${{ secrets.FILAMENT_ADVANCED_TABLES_USER }} + FILAMENT_ADVANCED_TABLES_PASSWORD: ${{ secrets.FILAMENT_ADVANCED_TABLES_PASSWORD }} diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml new file mode 100644 index 0000000..63e2356 --- /dev/null +++ b/.github/workflows/pint.yml @@ -0,0 +1,22 @@ +name: PHP Linting (Pint) + +on: + push: + paths: + - '**.php' + - '.github/workflows/pint.yml' + - 'pint.json' + branches-ignore: + - 'dependabot/npm_and_yarn/*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pint: + uses: TappNetwork/workflows/.github/workflows/pint.yml@main + with: + preset: 'laravel' + pint_config: 'pint.json' + commit_message: 'PHP Linting (Pint)' diff --git a/config/filament-library.php b/config/filament-library.php index 1aa277d..f5114a5 100644 --- a/config/filament-library.php +++ b/config/filament-library.php @@ -144,4 +144,31 @@ 'column' => null, ], + /* + |-------------------------------------------------------------------------- + | Cache Configuration + |-------------------------------------------------------------------------- + */ + 'cache' => [ + 'breadcrumbs_ttl_seconds' => 300, + ], + + /* + |-------------------------------------------------------------------------- + | File Preview Configuration + |-------------------------------------------------------------------------- + | + | Configure text-based file previews for markdown and JSON exports. + | + */ + 'preview' => [ + 'text_max_bytes' => 2 * 1024 * 1024, + 'markdown_extensions' => ['md', 'markdown', 'mdown'], + 'json_filename_patterns' => [ + 'quiz' => ['quiz'], + 'flashcards' => ['flashcard', 'flashcards'], + 'mindmap' => ['mindmap', 'mind-map', 'mind_map'], + ], + ], + ]; diff --git a/polyscope.json b/polyscope.json new file mode 100644 index 0000000..c13f801 --- /dev/null +++ b/polyscope.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "setup": "herd link", + "archive": "herd unlink" + }, + "preview": { + "url": "http://{{folder}}.test" + } +} diff --git a/resources/css/filament-library.css b/resources/css/filament-library.css index fba8875..7118822 100644 --- a/resources/css/filament-library.css +++ b/resources/css/filament-library.css @@ -64,3 +64,243 @@ height: 100%; object-fit: cover; } + +/* Audio Preview */ +.filament-library-audio-preview { + background-color: #f9fafb; + border-radius: 0.5rem; + padding: 1.5rem; +} + +.filament-library-audio-preview-label { + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: #6b7280; +} + +@media (prefers-color-scheme: dark) { + .filament-library-audio-preview { + background-color: #1f2937; + } + + .filament-library-audio-preview-label { + color: #9ca3af; + } +} + +/* Markdown Prose */ +.filament-library-prose { + max-width: none; + padding: 1.5rem; + border-radius: 0.5rem; + background-color: #ffffff; + color: #111827; + line-height: 1.7; +} + +.filament-library-prose h1, +.filament-library-prose h2, +.filament-library-prose h3 { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 600; +} + +.filament-library-prose ul, +.filament-library-prose ol { + margin: 0.75rem 0; + padding-left: 1.5rem; +} + +.filament-library-prose pre { + overflow-x: auto; + padding: 1rem; + border-radius: 0.375rem; + background-color: #f3f4f6; +} + +@media (prefers-color-scheme: dark) { + .filament-library-prose { + background-color: #111827; + color: #f3f4f6; + } + + .filament-library-prose pre { + background-color: #1f2937; + } +} + +/* JSON Structured Previews */ +.filament-library-json-preview { + padding: 1.5rem; + border-radius: 0.5rem; + background-color: #ffffff; + color: #111827; +} + +.filament-library-json-title { + margin-bottom: 1rem; + font-size: 1.25rem; + font-weight: 600; +} + +.filament-library-json-empty { + color: #6b7280; +} + +.filament-library-quiz-question { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #e5e7eb; +} + +.filament-library-quiz-question-number { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; +} + +.filament-library-quiz-question-stem { + margin: 0.5rem 0 0.75rem; + font-size: 1rem; + font-weight: 500; +} + +.filament-library-quiz-options { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.filament-library-quiz-option { + padding: 0.75rem 1rem; + border-radius: 0.375rem; + background-color: #f9fafb; + border: 1px solid #e5e7eb; +} + +.filament-library-quiz-option-correct { + border-color: #86efac; + background-color: #f0fdf4; +} + +.filament-library-quiz-correct-badge { + margin-left: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + color: #15803d; +} + +.filament-library-flashcards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; +} + +.filament-library-flashcard { + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + overflow: hidden; +} + +.filament-library-flashcard-front, +.filament-library-flashcard-back { + padding: 1rem; +} + +.filament-library-flashcard-front { + background-color: #f9fafb; + border-bottom: 1px solid #e5e7eb; +} + +.filament-library-flashcard-label { + display: block; + margin-bottom: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #6b7280; +} + +.filament-library-mindmap-tree { + list-style: none; + margin: 0; + padding-left: 0; +} + +.filament-library-mindmap-node { + margin: 0.25rem 0; +} + +.filament-library-mindmap-label { + display: inline-block; + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + background-color: #eff6ff; + border: 1px solid #bfdbfe; +} + +.filament-library-json-tree { + margin: 0; +} + +.filament-library-json-tree-item { + margin: 0.25rem 0; +} + +.filament-library-json-tree-key { + font-weight: 600; + color: #374151; +} + +.filament-library-json-tree-value { + color: #4b5563; +} + +@media (prefers-color-scheme: dark) { + .filament-library-json-preview { + background-color: #111827; + color: #f3f4f6; + } + + .filament-library-quiz-question { + border-bottom-color: #374151; + } + + .filament-library-quiz-option { + background-color: #1f2937; + border-color: #374151; + } + + .filament-library-quiz-option-correct { + background-color: #14532d; + border-color: #166534; + } + + .filament-library-flashcard { + border-color: #374151; + } + + .filament-library-flashcard-front { + background-color: #1f2937; + border-bottom-color: #374151; + } + + .filament-library-mindmap-label { + background-color: #1e3a5f; + border-color: #1d4ed8; + } + + .filament-library-json-tree-key { + color: #d1d5db; + } + + .filament-library-json-tree-value { + color: #9ca3af; + } +} diff --git a/src/Enums/LibraryFilePreviewType.php b/src/Enums/LibraryFilePreviewType.php new file mode 100644 index 0000000..be3c19e --- /dev/null +++ b/src/Enums/LibraryFilePreviewType.php @@ -0,0 +1,40 @@ + 'image', + self::Pdf => 'pdf', + self::Video => 'video', + self::Audio => 'audio', + self::Markdown => 'markdown', + self::JsonQuiz => 'json-quiz', + self::JsonFlashcards => 'json-flashcards', + self::JsonMindmap => 'json-mindmap', + self::JsonGeneric => 'json-generic', + self::Unsupported => 'download', + }; + } +} diff --git a/src/Models/LibraryItem.php b/src/Models/LibraryItem.php index 6219dbd..de373ba 100644 --- a/src/Models/LibraryItem.php +++ b/src/Models/LibraryItem.php @@ -544,12 +544,20 @@ public function registerMediaConversions(?Media $media = null): void ->height(300); } + /** + * Get the media item used for file previews and downloads. + */ + public function getPreviewMedia(): ?Media + { + return $this->getFirstMedia('files') ?? $this->getFirstMedia(); + } + /** * Get a secure URL for the file with temporary URL fallback. */ public function getSecureUrl(?int $expirationMinutes = null): string { - $media = $this->getFirstMedia(); + $media = $this->getPreviewMedia(); if (! $media) { return ''; diff --git a/src/Resources/Pages/EditLibraryItem.php b/src/Resources/Pages/EditLibraryItem.php index 82dbcc4..374c836 100644 --- a/src/Resources/Pages/EditLibraryItem.php +++ b/src/Resources/Pages/EditLibraryItem.php @@ -6,6 +6,7 @@ use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; use Tapp\FilamentLibrary\Resources\LibraryItemResource; +use Tapp\FilamentLibrary\Support\LibraryFolderBreadcrumbPath; class EditLibraryItem extends EditRecord { @@ -94,24 +95,10 @@ public function getBreadcrumbs(): array $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; + + foreach (LibraryFolderBreadcrumbPath::ancestorsForParentId($record->parent_id) as $folder) { + $breadcrumbs[$baseUrl . '?parent=' . $folder['id']] = $folder['name']; } } diff --git a/src/Resources/Pages/ListLibraryItems.php b/src/Resources/Pages/ListLibraryItems.php index 86506f1..e444218 100644 --- a/src/Resources/Pages/ListLibraryItems.php +++ b/src/Resources/Pages/ListLibraryItems.php @@ -12,6 +12,7 @@ use Tapp\FilamentLibrary\FilamentLibraryPlugin; use Tapp\FilamentLibrary\Models\LibraryItem; use Tapp\FilamentLibrary\Resources\LibraryItemResource; +use Tapp\FilamentLibrary\Support\LibraryFolderBreadcrumbPath; class ListLibraryItems extends ListRecords { @@ -279,26 +280,10 @@ public function getBreadcrumbs(): array ]; 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) { - if (isset($folder->id) && isset($folder->name)) { - $breadcrumbs[$baseUrl . '?parent=' . $folder->id] = $folder->name; - } + + foreach (LibraryFolderBreadcrumbPath::ancestorsForFolder($this->parentFolder) as $folder) { + $breadcrumbs[$baseUrl . '?parent=' . $folder['id']] = $folder['name']; } } diff --git a/src/Resources/Pages/ViewLibraryItem.php b/src/Resources/Pages/ViewLibraryItem.php index ab08d73..7cfe198 100644 --- a/src/Resources/Pages/ViewLibraryItem.php +++ b/src/Resources/Pages/ViewLibraryItem.php @@ -13,6 +13,7 @@ use Filament\Schemas\Schema; use Tapp\FilamentLibrary\Infolists\Components\VideoEmbed; use Tapp\FilamentLibrary\Resources\LibraryItemResource; +use Tapp\FilamentLibrary\Support\LibraryFolderBreadcrumbPath; class ViewLibraryItem extends ViewRecord { @@ -110,25 +111,10 @@ public function getBreadcrumbs(): array $record = $this->getRecord(); if ($record->parent_id) { - // Cache the breadcrumb path to avoid repeated computation - $cacheKey = 'breadcrumbs_' . $record->parent_id; - $cacheTtl = config('filament-library.cache.breadcrumbs_ttl_seconds', 300); - $path = cache()->remember($cacheKey, $cacheTtl, function () use ($record) { - $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; + + foreach (LibraryFolderBreadcrumbPath::ancestorsForParentId($record->parent_id) as $folder) { + $breadcrumbs[$baseUrl . '?parent=' . $folder['id']] = $folder['name']; } } diff --git a/src/Resources/views/infolists/components/file-preview.blade.php b/src/Resources/views/infolists/components/file-preview.blade.php index 4de1c64..e4c4ed1 100644 --- a/src/Resources/views/infolists/components/file-preview.blade.php +++ b/src/Resources/views/infolists/components/file-preview.blade.php @@ -1,89 +1,27 @@ @php - $media = $record->getFirstMedia(); + use Tapp\FilamentLibrary\Support\LibraryFilePreviewResolver; + + $media = $record->getFirstMedia('files') ?? $record->getFirstMedia(); $fileUrl = $record->getSecureUrl(); + $preview = $media ? LibraryFilePreviewResolver::resolve($media) : null; @endphp -@if($media && $fileUrl) - @php - $mimeType = $media->mime_type; - $extension = strtolower(pathinfo($media->name, PATHINFO_EXTENSION)); - - // Define previewable file types - $previewableImages = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']; - $previewableDocuments = ['pdf']; - $previewableVideos = ['mp4', 'webm', 'ogg', 'avi', 'mov']; - $previewableAudio = ['mp3', 'wav', 'ogg', 'm4a', 'aac']; - - // Check if file can be previewed - $canPreview = false; - $previewType = null; - - if (str_starts_with($mimeType, 'image/') || in_array($extension, $previewableImages)) { - $canPreview = true; - $previewType = 'image'; - } elseif (in_array($extension, $previewableDocuments) || $mimeType === 'application/pdf') { - $canPreview = true; - $previewType = 'pdf'; - } elseif (str_starts_with($mimeType, 'video/') || in_array($extension, $previewableVideos)) { - $canPreview = true; - $previewType = 'video'; - } elseif (str_starts_with($mimeType, 'audio/') || in_array($extension, $previewableAudio)) { - $canPreview = true; - $previewType = 'audio'; - } - @endphp - +@if($media && $fileUrl && $preview?->isPreviewable())
- @if($canPreview) - @if($previewType === 'image') - {{-- Image preview --}} - {{ $media->name }} - @elseif($previewType === 'pdf') - {{-- PDF preview --}} - - @elseif($previewType === 'video') - {{-- Video preview --}} -
- -
- @elseif($previewType === 'audio') - {{-- Audio preview --}} -
-
Audio Preview:
- -
- @endif - @else - {{-- File cannot be previewed - show download option --}} - -
-
- This file type cannot be previewed. Please download to view. -
- -
- - Download File - -
-
-
- @endif + @include('filament-library::infolists.components.previews.'.$preview->type->viewName(), [ + 'media' => $media, + 'fileUrl' => $fileUrl, + 'preview' => $preview, + ]) +
+@elseif($media && $fileUrl) +
+ @include('filament-library::infolists.components.previews.download', [ + 'fileUrl' => $fileUrl, + 'message' => $preview?->fallbackMessage ?? 'This file type cannot be previewed. Please download to view.', + ])
@else - {{-- No file associated with this record --}}
diff --git a/src/Resources/views/infolists/components/previews/audio.blade.php b/src/Resources/views/infolists/components/previews/audio.blade.php new file mode 100644 index 0000000..8d5e639 --- /dev/null +++ b/src/Resources/views/infolists/components/previews/audio.blade.php @@ -0,0 +1,7 @@ +
+
Audio Preview:
+ +
diff --git a/src/Resources/views/infolists/components/previews/download.blade.php b/src/Resources/views/infolists/components/previews/download.blade.php new file mode 100644 index 0000000..737d242 --- /dev/null +++ b/src/Resources/views/infolists/components/previews/download.blade.php @@ -0,0 +1,19 @@ + +
+
+ {{ $message }} +
+ +
+ + Download File + +
+
+
diff --git a/src/Resources/views/infolists/components/previews/image.blade.php b/src/Resources/views/infolists/components/previews/image.blade.php new file mode 100644 index 0000000..c93d505 --- /dev/null +++ b/src/Resources/views/infolists/components/previews/image.blade.php @@ -0,0 +1 @@ +{{ $media->name }} diff --git a/src/Resources/views/infolists/components/previews/json-flashcards.blade.php b/src/Resources/views/infolists/components/previews/json-flashcards.blade.php new file mode 100644 index 0000000..63ed07e --- /dev/null +++ b/src/Resources/views/infolists/components/previews/json-flashcards.blade.php @@ -0,0 +1,38 @@ +@php + /** @var array $data */ + $data = $preview->parsedJson ?? []; + $title = is_string($data['title'] ?? null) ? $data['title'] : null; + $cards = is_array($data['cards'] ?? null) ? $data['cards'] : []; +@endphp + +
+ @if($title) +

{{ $title }}

+ @endif + +
+ @forelse($cards as $index => $card) + @if(! is_array($card)) + @continue + @endif + + @php + $front = $card['front'] ?? $card['term'] ?? $card['question'] ?? 'Card '.($index + 1); + $back = $card['back'] ?? $card['definition'] ?? $card['answer'] ?? ''; + @endphp + +
+
+ Front +
{{ $front }}
+
+
+ Back +
{{ $back }}
+
+
+ @empty +

No flashcards found in this file.

+ @endforelse +
+
diff --git a/src/Resources/views/infolists/components/previews/json-generic.blade.php b/src/Resources/views/infolists/components/previews/json-generic.blade.php new file mode 100644 index 0000000..e76b2fc --- /dev/null +++ b/src/Resources/views/infolists/components/previews/json-generic.blade.php @@ -0,0 +1,11 @@ +@php + /** @var array $data */ + $data = $preview->parsedJson ?? []; +@endphp + +
+ @include('filament-library::infolists.components.previews.partials.json-tree', [ + 'data' => $data, + 'depth' => 0, + ]) +
diff --git a/src/Resources/views/infolists/components/previews/json-mindmap.blade.php b/src/Resources/views/infolists/components/previews/json-mindmap.blade.php new file mode 100644 index 0000000..a8f5c12 --- /dev/null +++ b/src/Resources/views/infolists/components/previews/json-mindmap.blade.php @@ -0,0 +1,27 @@ +@php + /** @var array $data */ + $data = $preview->parsedJson ?? []; + $title = is_string($data['title'] ?? null) ? $data['title'] : null; + $root = $data['root'] ?? $data; +@endphp + +
+ @if($title) +

{{ $title }}

+ @endif + + @if(isset($data['nodes']) && is_array($data['nodes'])) +
    + @foreach($data['nodes'] as $node) + @if(! is_array($node)) + @continue + @endif +
  • + {{ $node['label'] ?? $node['title'] ?? $node['name'] ?? $node['text'] ?? 'Node' }} +
  • + @endforeach +
+ @else + @include('filament-library::infolists.components.previews.partials.mindmap-node', ['node' => $root, 'depth' => 0]) + @endif +
diff --git a/src/Resources/views/infolists/components/previews/json-quiz.blade.php b/src/Resources/views/infolists/components/previews/json-quiz.blade.php new file mode 100644 index 0000000..59211ee --- /dev/null +++ b/src/Resources/views/infolists/components/previews/json-quiz.blade.php @@ -0,0 +1,54 @@ +@php + /** @var array $data */ + $data = $preview->parsedJson ?? []; + $title = is_string($data['title'] ?? null) ? $data['title'] : null; + $questions = is_array($data['questions'] ?? null) ? $data['questions'] : []; +@endphp + +
+ @if($title) +

{{ $title }}

+ @endif + + @forelse($questions as $index => $question) + @if(! is_array($question)) + @continue + @endif + + @php + $stem = $question['stem'] ?? $question['question'] ?? $question['text'] ?? 'Question '.($index + 1); + $options = $question['options'] ?? $question['choices'] ?? $question['answers'] ?? []; + $correct = $question['correct'] ?? $question['correct_answer'] ?? $question['answer'] ?? null; + @endphp + +
+
Question {{ $index + 1 }}
+
{{ $stem }}
+ + @if(is_array($options) && $options !== []) +
    + @foreach($options as $optionIndex => $option) + @php + $label = is_array($option) + ? ($option['text'] ?? $option['label'] ?? $option['value'] ?? json_encode($option)) + : $option; + $isCorrect = $correct !== null && ( + $correct === $option + || $correct === $optionIndex + || (is_array($option) && (($option['id'] ?? null) === $correct || ($option['value'] ?? null) === $correct)) + ); + @endphp +
  • $isCorrect])> + {{ $label }} + @if($isCorrect) + Correct + @endif +
  • + @endforeach +
+ @endif +
+ @empty +

No questions found in this quiz file.

+ @endforelse +
diff --git a/src/Resources/views/infolists/components/previews/markdown.blade.php b/src/Resources/views/infolists/components/previews/markdown.blade.php new file mode 100644 index 0000000..73d23bb --- /dev/null +++ b/src/Resources/views/infolists/components/previews/markdown.blade.php @@ -0,0 +1,9 @@ +@php + use Illuminate\Support\Str; + + $html = Str::markdown($preview->textContent ?? ''); +@endphp + +
+ {!! $html !!} +
diff --git a/src/Resources/views/infolists/components/previews/partials/json-tree.blade.php b/src/Resources/views/infolists/components/previews/partials/json-tree.blade.php new file mode 100644 index 0000000..6e139af --- /dev/null +++ b/src/Resources/views/infolists/components/previews/partials/json-tree.blade.php @@ -0,0 +1,39 @@ +@php + $isList = array_is_list($data); +@endphp + +@if($isList) +
    + @foreach($data as $index => $item) +
  • + [{{ $index }}] + @if(is_array($item)) + @include('filament-library::infolists.components.previews.partials.json-tree', [ + 'data' => $item, + 'depth' => $depth + 1, + ]) + @else + {{ is_scalar($item) || $item === null ? (string) $item : json_encode($item) }} + @endif +
  • + @endforeach +
+@else +
+ @foreach($data as $key => $value) +
+
{{ $key }}
+
+ @if(is_array($value)) + @include('filament-library::infolists.components.previews.partials.json-tree', [ + 'data' => $value, + 'depth' => $depth + 1, + ]) + @else + {{ is_scalar($value) || $value === null ? (string) $value : json_encode($value) }} + @endif +
+
+ @endforeach +
+@endif diff --git a/src/Resources/views/infolists/components/previews/partials/mindmap-node.blade.php b/src/Resources/views/infolists/components/previews/partials/mindmap-node.blade.php new file mode 100644 index 0000000..2adb0e6 --- /dev/null +++ b/src/Resources/views/infolists/components/previews/partials/mindmap-node.blade.php @@ -0,0 +1,30 @@ +@php + $label = is_array($node) + ? ($node['label'] ?? $node['title'] ?? $node['name'] ?? $node['text'] ?? null) + : (is_string($node) ? $node : null); + $children = is_array($node) && isset($node['children']) && is_array($node['children']) + ? $node['children'] + : []; +@endphp + +@if($label !== null) +
    0) style="margin-left: {{ min($depth * 1.25, 6) }}rem;" @endif> +
  • + {{ $label }} + + @foreach($children as $child) + @include('filament-library::infolists.components.previews.partials.mindmap-node', [ + 'node' => $child, + 'depth' => $depth + 1, + ]) + @endforeach +
  • +
+@elseif($children !== []) + @foreach($children as $child) + @include('filament-library::infolists.components.previews.partials.mindmap-node', [ + 'node' => $child, + 'depth' => $depth, + ]) + @endforeach +@endif diff --git a/src/Resources/views/infolists/components/previews/pdf.blade.php b/src/Resources/views/infolists/components/previews/pdf.blade.php new file mode 100644 index 0000000..5765d9a --- /dev/null +++ b/src/Resources/views/infolists/components/previews/pdf.blade.php @@ -0,0 +1 @@ + diff --git a/src/Resources/views/infolists/components/previews/video.blade.php b/src/Resources/views/infolists/components/previews/video.blade.php new file mode 100644 index 0000000..068f68a --- /dev/null +++ b/src/Resources/views/infolists/components/previews/video.blade.php @@ -0,0 +1,6 @@ +
+ +
diff --git a/src/Support/LibraryFileContentReader.php b/src/Support/LibraryFileContentReader.php new file mode 100644 index 0000000..b94b563 --- /dev/null +++ b/src/Support/LibraryFileContentReader.php @@ -0,0 +1,37 @@ +size > $maxBytes) { + return null; + } + + try { + $path = $media->getPath(); + + if (! is_readable($path)) { + return null; + } + + $content = file_get_contents($path, false, null, 0, $maxBytes + 1); + + if ($content === false || strlen($content) > $maxBytes) { + return null; + } + + return $content; + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Support/LibraryFilePreview.php b/src/Support/LibraryFilePreview.php new file mode 100644 index 0000000..cf97d45 --- /dev/null +++ b/src/Support/LibraryFilePreview.php @@ -0,0 +1,27 @@ +|null $parsedJson + */ + public function __construct( + public readonly LibraryFilePreviewType $type, + public readonly string $mimeType, + public readonly string $extension, + public readonly ?string $textContent = null, + public readonly ?array $parsedJson = null, + public readonly ?string $fallbackMessage = null, + ) {} + + public function isPreviewable(): bool + { + return $this->type->isPreviewable(); + } +} diff --git a/src/Support/LibraryFilePreviewResolver.php b/src/Support/LibraryFilePreviewResolver.php new file mode 100644 index 0000000..781b68a --- /dev/null +++ b/src/Support/LibraryFilePreviewResolver.php @@ -0,0 +1,325 @@ + */ + private const PREVIEWABLE_IMAGES = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']; + + /** @var list */ + private const PREVIEWABLE_VIDEOS = ['mp4', 'webm', 'ogg', 'avi', 'mov']; + + /** @var list */ + private const PREVIEWABLE_AUDIO = ['mp3', 'wav', 'ogg', 'm4a', 'aac']; + + /** @var list */ + private const PDF_MIME_TYPES = ['application/pdf', 'application/x-pdf']; + + public static function resolve(Media $media): LibraryFilePreview + { + $mimeType = strtolower((string) $media->mime_type); + $extension = self::resolveExtension($media); + + if (self::isImage($mimeType, $extension)) { + return new LibraryFilePreview(LibraryFilePreviewType::Image, $mimeType, $extension); + } + + if (self::isPdf($mimeType, $extension)) { + return new LibraryFilePreview(LibraryFilePreviewType::Pdf, $mimeType, $extension); + } + + if (self::isVideo($mimeType, $extension)) { + return new LibraryFilePreview(LibraryFilePreviewType::Video, $mimeType, $extension); + } + + if (self::isAudio($mimeType, $extension)) { + return new LibraryFilePreview(LibraryFilePreviewType::Audio, $mimeType, $extension); + } + + if (self::isMarkdown($mimeType, $extension)) { + $content = LibraryFileContentReader::read($media); + + if ($content === null) { + return self::unsupported( + $mimeType, + $extension, + 'This file is too large to preview. Please download to view.', + ); + } + + return new LibraryFilePreview( + LibraryFilePreviewType::Markdown, + $mimeType, + $extension, + textContent: $content, + ); + } + + if (self::isJson($mimeType, $extension)) { + return self::resolveJsonPreview($media, $mimeType, $extension); + } + + return self::unsupported( + $mimeType, + $extension, + 'This file type cannot be previewed. Please download to view.', + ); + } + + public static function resolveExtension(Media $media): string + { + $fileNameExtension = strtolower(pathinfo((string) $media->file_name, PATHINFO_EXTENSION)); + + if ($fileNameExtension !== '') { + return $fileNameExtension; + } + + return strtolower(pathinfo((string) $media->name, PATHINFO_EXTENSION)); + } + + private static function isImage(string $mimeType, string $extension): bool + { + return str_starts_with($mimeType, 'image/') + || in_array($extension, self::PREVIEWABLE_IMAGES, true); + } + + private static function isPdf(string $mimeType, string $extension): bool + { + if (in_array($mimeType, self::PDF_MIME_TYPES, true)) { + return true; + } + + return $extension === 'pdf'; + } + + private static function isVideo(string $mimeType, string $extension): bool + { + return str_starts_with($mimeType, 'video/') + || in_array($extension, self::PREVIEWABLE_VIDEOS, true); + } + + private static function isAudio(string $mimeType, string $extension): bool + { + return str_starts_with($mimeType, 'audio/') + || in_array($extension, self::PREVIEWABLE_AUDIO, true); + } + + private static function isMarkdown(string $mimeType, string $extension): bool + { + /** @var list $markdownExtensions */ + $markdownExtensions = config('filament-library.preview.markdown_extensions', ['md', 'markdown', 'mdown']); + + if (in_array($extension, $markdownExtensions, true)) { + return true; + } + + return in_array($mimeType, ['text/markdown', 'text/x-markdown'], true); + } + + private static function isJson(string $mimeType, string $extension): bool + { + return $extension === 'json' + || in_array($mimeType, ['application/json', 'text/json'], true); + } + + private static function resolveJsonPreview(Media $media, string $mimeType, string $extension): LibraryFilePreview + { + $content = LibraryFileContentReader::read($media); + + if ($content === null) { + return self::unsupported( + $mimeType, + $extension, + 'This file is too large to preview. Please download to view.', + ); + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return self::unsupported( + $mimeType, + $extension, + 'This JSON file could not be parsed. Please download to view.', + ); + } + + if (! is_array($decoded)) { + return new LibraryFilePreview( + LibraryFilePreviewType::JsonGeneric, + $mimeType, + $extension, + parsedJson: ['value' => $decoded], + ); + } + + $filenameHint = self::resolveJsonTypeFromFilename($media); + $schemaHint = self::resolveJsonTypeFromSchema($decoded); + + $type = $filenameHint ?? $schemaHint ?? LibraryFilePreviewType::JsonGeneric; + + return new LibraryFilePreview( + $type, + $mimeType, + $extension, + parsedJson: $decoded, + ); + } + + private static function resolveJsonTypeFromFilename(Media $media): ?LibraryFilePreviewType + { + $haystack = strtolower((string) $media->file_name . ' ' . (string) $media->name); + + /** @var array> $patterns */ + $patterns = config('filament-library.preview.json_filename_patterns', [ + 'quiz' => ['quiz'], + 'flashcards' => ['flashcard', 'flashcards'], + 'mindmap' => ['mindmap', 'mind-map', 'mind_map'], + ]); + + foreach ($patterns['quiz'] ?? ['quiz'] as $pattern) { + if (str_contains($haystack, strtolower($pattern))) { + return LibraryFilePreviewType::JsonQuiz; + } + } + + foreach ($patterns['flashcards'] ?? ['flashcard', 'flashcards'] as $pattern) { + if (str_contains($haystack, strtolower($pattern))) { + return LibraryFilePreviewType::JsonFlashcards; + } + } + + foreach ($patterns['mindmap'] ?? ['mindmap', 'mind-map', 'mind_map'] as $pattern) { + if (str_contains($haystack, strtolower($pattern))) { + return LibraryFilePreviewType::JsonMindmap; + } + } + + return null; + } + + /** + * @param array $decoded + */ + private static function resolveJsonTypeFromSchema(array $decoded): ?LibraryFilePreviewType + { + if (self::looksLikeQuiz($decoded)) { + return LibraryFilePreviewType::JsonQuiz; + } + + if (self::looksLikeFlashcards($decoded)) { + return LibraryFilePreviewType::JsonFlashcards; + } + + if (self::looksLikeMindmap($decoded)) { + return LibraryFilePreviewType::JsonMindmap; + } + + return null; + } + + /** + * @param array $decoded + */ + private static function looksLikeQuiz(array $decoded): bool + { + if (! isset($decoded['questions']) || ! is_array($decoded['questions'])) { + return false; + } + + foreach ($decoded['questions'] as $question) { + if (! is_array($question)) { + continue; + } + + $hasPrompt = isset($question['stem']) || isset($question['question']) || isset($question['text']); + $hasOptions = isset($question['options']) || isset($question['choices']) || isset($question['answers']); + + if ($hasPrompt && $hasOptions) { + return true; + } + } + + return false; + } + + /** + * @param array $decoded + */ + private static function looksLikeFlashcards(array $decoded): bool + { + if (! isset($decoded['cards']) || ! is_array($decoded['cards'])) { + return false; + } + + foreach ($decoded['cards'] as $card) { + if (! is_array($card)) { + continue; + } + + $hasFront = isset($card['front']) || isset($card['term']) || isset($card['question']); + $hasBack = isset($card['back']) || isset($card['definition']) || isset($card['answer']); + + if ($hasFront && $hasBack) { + return true; + } + } + + return false; + } + + /** + * @param array $decoded + */ + private static function looksLikeMindmap(array $decoded): bool + { + if (isset($decoded['nodes'], $decoded['edges']) && is_array($decoded['nodes'])) { + return true; + } + + if (isset($decoded['root']) && is_array($decoded['root'])) { + return true; + } + + if (self::hasNestedChildren($decoded)) { + return true; + } + + return isset($decoded['children']) && is_array($decoded['children']); + } + + /** + * @param array $decoded + */ + private static function hasNestedChildren(array $decoded): bool + { + if (! isset($decoded['children']) || ! is_array($decoded['children'])) { + return false; + } + + foreach ($decoded['children'] as $child) { + if (is_array($child) && (isset($child['children']) || isset($child['label']) || isset($child['title']) || isset($child['name']))) { + return true; + } + } + + return false; + } + + private static function unsupported(string $mimeType, string $extension, string $message): LibraryFilePreview + { + return new LibraryFilePreview( + LibraryFilePreviewType::Unsupported, + $mimeType, + $extension, + fallbackMessage: $message, + ); + } +} diff --git a/src/Support/LibraryFolderBreadcrumbPath.php b/src/Support/LibraryFolderBreadcrumbPath.php new file mode 100644 index 0000000..23939dd --- /dev/null +++ b/src/Support/LibraryFolderBreadcrumbPath.php @@ -0,0 +1,115 @@ + + */ + public static function ancestorsForParentId(?int $parentId): array + { + if ($parentId === null) { + return []; + } + + $cacheKey = self::cacheKey($parentId); + $cacheTtl = (int) config('filament-library.cache.breadcrumbs_ttl_seconds', 300); + + /** @var list $path */ + $path = cache()->remember($cacheKey, $cacheTtl, function () use ($parentId): array { + return self::buildPath($parentId); + }); + + return $path; + } + + /** + * @return list + */ + public static function ancestorsForFolder(LibraryItem $folder): array + { + $folderId = $folder->getKey(); + + if (! is_int($folderId)) { + return self::buildPathFromFolder($folder); + } + + $cacheKey = self::cacheKey($folderId); + $cacheTtl = (int) config('filament-library.cache.breadcrumbs_ttl_seconds', 300); + + /** @var list $path */ + $path = cache()->remember($cacheKey, $cacheTtl, function () use ($folder): array { + return self::buildPathFromFolder($folder); + }); + + return $path; + } + + /** + * @return list + */ + private static function buildPath(int $parentId): array + { + /** @var class-string $modelClass */ + $modelClass = FilamentLibraryPlugin::libraryItemModelClass(); + + $folder = $modelClass::query()->find($parentId); + + if ($folder === null) { + return []; + } + + return self::buildPathFromFolder($folder); + } + + /** + * @return list + */ + private static function buildPathFromFolder(LibraryItem $folder): array + { + $path = []; + $current = $folder; + + while ($current) { + $currentId = $current->getKey(); + + if (! is_int($currentId)) { + break; + } + + array_unshift($path, [ + 'id' => $currentId, + 'name' => (string) $current->name, + ]); + + $current = $current->parent; + } + + return $path; + } + + private static function cacheKey(int $folderId): string + { + $tenantSegment = 'global'; + + if (config('filament-library.tenancy.enabled') && class_exists(Filament::class)) { + $tenant = Filament::getTenant(); + + if ($tenant !== null) { + $tenantKey = $tenant->getKey(); + $tenantSegment = is_int($tenantKey) || is_string($tenantKey) + ? (string) $tenantKey + : 'global'; + } + } + + return "filament-library.breadcrumbs.{$tenantSegment}.{$folderId}"; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 5bca49f..8dc7e5e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,4 +2,5 @@ use Tapp\FilamentLibrary\Tests\TestCase; -uses(TestCase::class)->in(__DIR__); +uses(TestCase::class)->in('Feature'); +uses(TestCase::class)->in('Unit'); diff --git a/tests/FilamentLibraryPluginTest.php b/tests/Unit/FilamentLibraryPluginTest.php similarity index 100% rename from tests/FilamentLibraryPluginTest.php rename to tests/Unit/FilamentLibraryPluginTest.php diff --git a/tests/Unit/LibraryFilePreviewResolverTest.php b/tests/Unit/LibraryFilePreviewResolverTest.php new file mode 100644 index 0000000..62afeeb --- /dev/null +++ b/tests/Unit/LibraryFilePreviewResolverTest.php @@ -0,0 +1,195 @@ +put($diskPath, $content); + } + + $media = new Media(array_merge([ + 'disk' => 'public', + 'conversions_disk' => 'public', + 'collection_name' => 'files', + 'manipulations' => [], + 'custom_properties' => [], + 'generated_conversions' => [], + 'responsive_images' => [], + ], $attributes)); + + if ($diskPath !== null) { + $media->forceFill([ + 'id' => 1, + ]); + + $media->setRawAttributes(array_merge($media->getAttributes(), [ + 'id' => 1, + ]), true); + + $fullPath = Storage::disk('public')->path($diskPath); + + $media = Mockery::mock($media)->makePartial(); + $media->shouldReceive('getPath')->andReturn($fullPath); + } + + return $media; +} + +it('detects pdf previews from file_name when display name lacks an extension', function (): void { + $media = previewMedia([ + 'file_name' => 'hashed-slides.pdf', + 'name' => 'Q1 Sales Slides', + 'mime_type' => 'application/octet-stream', + 'size' => 1024, + ]); + + $preview = LibraryFilePreviewResolver::resolve($media); + + expect($preview->type)->toBe(LibraryFilePreviewType::Pdf) + ->and($preview->isPreviewable())->toBeTrue(); +}); + +it('detects markdown previews and reads content from storage', function (): void { + $content = "# Atlas Notes\n\n- First point\n- Second point"; + + $media = previewMedia([ + 'file_name' => 'notes.md', + 'name' => 'Atlas Notes', + 'mime_type' => 'text/markdown', + 'size' => strlen($content), + ], 'library/notes.md', $content); + + $preview = LibraryFilePreviewResolver::resolve($media); + + expect($preview->type)->toBe(LibraryFilePreviewType::Markdown) + ->and($preview->textContent)->toBe($content); +}); + +it('detects quiz json from filename patterns', function (): void { + $content = json_encode([ + 'title' => 'Product Quiz', + 'questions' => [ + [ + 'stem' => 'What is Laravel?', + 'options' => ['A framework', 'A database'], + 'correct' => 'A framework', + ], + ], + ], JSON_THROW_ON_ERROR); + + $media = previewMedia([ + 'file_name' => 'topic-quiz.json', + 'name' => 'Topic Quiz', + 'mime_type' => 'application/json', + 'size' => strlen($content), + ], 'library/topic-quiz.json', $content); + + $preview = LibraryFilePreviewResolver::resolve($media); + + expect($preview->type)->toBe(LibraryFilePreviewType::JsonQuiz) + ->and($preview->parsedJson['title'] ?? null)->toBe('Product Quiz'); +}); + +it('detects flashcards json from schema heuristics', function (): void { + $content = json_encode([ + 'cards' => [ + ['front' => 'Term', 'back' => 'Definition'], + ], + ], JSON_THROW_ON_ERROR); + + $media = previewMedia([ + 'file_name' => 'study-set.json', + 'name' => 'Study Set', + 'mime_type' => 'application/json', + 'size' => strlen($content), + ], 'library/study-set.json', $content); + + $preview = LibraryFilePreviewResolver::resolve($media); + + expect($preview->type)->toBe(LibraryFilePreviewType::JsonFlashcards); +}); + +it('detects mind map json from nested children', function (): void { + $content = json_encode([ + 'title' => 'Topic Map', + 'label' => 'Root', + 'children' => [ + ['label' => 'Branch A', 'children' => [['label' => 'Leaf']]], + ], + ], JSON_THROW_ON_ERROR); + + $media = previewMedia([ + 'file_name' => 'topic-mindmap.json', + 'name' => 'Topic Map', + 'mime_type' => 'application/json', + 'size' => strlen($content), + ], 'library/topic-mindmap.json', $content); + + $preview = LibraryFilePreviewResolver::resolve($media); + + expect($preview->type)->toBe(LibraryFilePreviewType::JsonMindmap); +}); + +it('falls back to unsupported when text content exceeds preview limit', function (): void { + config()->set('filament-library.preview.text_max_bytes', 10); + + $content = str_repeat('a', 20); + + $media = previewMedia([ + 'file_name' => 'notes.md', + 'name' => 'Large Notes', + 'mime_type' => 'text/markdown', + 'size' => strlen($content), + ], 'library/large-notes.md', $content); + + $preview = LibraryFilePreviewResolver::resolve($media); + + expect($preview->type)->toBe(LibraryFilePreviewType::Unsupported) + ->and($preview->fallbackMessage)->toContain('too large'); +}); + +it('falls back to unsupported when json is invalid', function (): void { + $content = '{not valid json'; + + $media = previewMedia([ + 'file_name' => 'broken.json', + 'name' => 'Broken JSON', + 'mime_type' => 'application/json', + 'size' => strlen($content), + ], 'library/broken.json', $content); + + $preview = LibraryFilePreviewResolver::resolve($media); + + expect($preview->type)->toBe(LibraryFilePreviewType::Unsupported) + ->and($preview->fallbackMessage)->toContain('could not be parsed'); +}); + +it('renders markdown preview blade with formatted html', function (): void { + $content = "# Heading\n\nParagraph text."; + + $media = previewMedia([ + 'file_name' => 'notes.md', + 'name' => 'Notes', + 'mime_type' => 'text/markdown', + 'size' => strlen($content), + ], 'library/notes.md', $content); + + $preview = LibraryFilePreviewResolver::resolve($media); + + $html = view('filament-library::infolists.components.previews.markdown', [ + 'media' => $media, + 'fileUrl' => 'https://example.test/notes.md', + 'preview' => $preview, + ])->render(); + + expect($html)->toContain('and($html)->toContain('Paragraph text.'); +}); diff --git a/tests/Unit/LibraryFolderBreadcrumbPathTest.php b/tests/Unit/LibraryFolderBreadcrumbPathTest.php new file mode 100644 index 0000000..6dbe9f9 --- /dev/null +++ b/tests/Unit/LibraryFolderBreadcrumbPathTest.php @@ -0,0 +1,35 @@ + 4, 'name' => 'Atlas Preview Samples'], + ]; + + Cache::put('filament-library.breadcrumbs.global.4', $path, 300); + + $cached = Cache::get('filament-library.breadcrumbs.global.4'); + + expect($cached)->toBe($path) + ->and($cached[0]['name'])->toBe('Atlas Preview Samples'); +}); + +it('does not return incomplete class objects from breadcrumb cache', function (): void { + Cache::flush(); + + Cache::put('breadcrumbs_4', [(object) ['id' => 4]], 300); + + $legacyCached = Cache::get('breadcrumbs_4'); + + expect($legacyCached[0])->toBeObject(); + + $path = LibraryFolderBreadcrumbPath::ancestorsForParentId(null); + + expect($path)->toBe([]); +}); diff --git a/tests/Unit/Pest.php b/tests/Unit/Pest.php new file mode 100644 index 0000000..1aa56d1 --- /dev/null +++ b/tests/Unit/Pest.php @@ -0,0 +1,9 @@ +