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 --}}
-

- @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 @@
+

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
+
+
+ @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 @@
+