Skip to main content

General

File Manager code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/file-manager.blade.php

@props([
    'title' => 'Files',
    'items' => [],
    'breadcrumbs' => [],
    'view' => 'grid',
    'search' => true,
    'selectable' => true,
    'usage' => null,
    'uploadHref' => null,
    'emptyTitle' => 'This folder is empty',
    'emptyDescription' => 'Upload files or create a folder to get started.',
])

@php
    /*
    | File manager / browser. A toolbar (breadcrumbs, search, view toggle, sort,
    | actions), a grid or list of folders and files with type-aware icons and
    | image thumbnails, multi-select with a contextual action bar, and an empty
    | state. Optionally shows a storage-usage meter.
    |
    | Each item: [
    |   'id', 'name', 'kind' => 'folder'|'file', 'type' (label), 'size', 'items'
    |   (folder child count), 'modified', 'icon', 'thumbnail', 'href',
    | ]
    |
    | Usage:
    |   <x-ui.file-manager :items="$files" :breadcrumbs="[['label' => 'Home', 'href' => '/files']]" />
    |   <x-ui.file-manager view="list" :usage="['used' => '64 GB', 'total' => '100 GB', 'percent' => 64]" />
    */
    $iconMap = [
        'folder' => ['icon' => 'folder', 'color' => 'text-amber-500'],
        'image' => ['icon' => 'image', 'color' => 'text-accent-500 dark:text-accent-400'],
        'video' => ['icon' => 'file-video', 'color' => 'text-brand-500 dark:text-brand-400'],
        'audio' => ['icon' => 'file-audio', 'color' => 'text-brand-500 dark:text-brand-400'],
        'pdf' => ['icon' => 'file-pdf', 'color' => 'text-red-500'],
        'archive' => ['icon' => 'file-zip', 'color' => 'text-amber-600'],
        'spreadsheet' => ['icon' => 'file-xls', 'color' => 'text-green-500'],
        'document' => ['icon' => 'file-doc', 'color' => 'text-accent-600 dark:text-accent-400'],
        'code' => ['icon' => 'file-code', 'color' => 'text-green-600 dark:text-green-400'],
        'text' => ['icon' => 'file-text', 'color' => 'text-gray-500'],
        'file' => ['icon' => 'file', 'color' => 'text-gray-400'],
    ];

    $normalized = collect($items)->map(function ($item, $index) use ($iconMap) {
        $kind = $item['kind'] ?? 'file';
        $type = $item['type'] ?? ($kind === 'folder' ? 'folder' : 'file');
        $resolved = $iconMap[$type] ?? ($kind === 'folder' ? $iconMap['folder'] : $iconMap['file']);

        return [
            'id' => (string) ($item['id'] ?? $index),
            'name' => $item['name'] ?? 'Untitled',
            'kind' => $kind,
            'type' => $type,
            'size' => $item['size'] ?? null,
            'count' => $item['items'] ?? null,
            'modified' => $item['modified'] ?? null,
            'icon' => $item['icon'] ?? $resolved['icon'],
            'color' => $resolved['color'],
            'thumbnail' => $item['thumbnail'] ?? null,
            'href' => $item['href'] ?? null,
        ];
    })->values();

    $ids = $normalized->pluck('id')->all();
    $hasItems = $normalized->isNotEmpty();
@endphp

<section
    x-data="{
        view: @js(in_array($view, ['grid', 'list'], true) ? $view : 'grid'),
        query: '',
        sort: 'name',
        selectable: @js((bool) $selectable),
        selected: [],
        items: @js($normalized),
        allIds: @js($ids),
        get filtered() {
            let list = this.items;
            if (this.query.trim()) {
                const needle = this.query.trim().toLowerCase();
                list = list.filter((item) => item.name.toLowerCase().includes(needle));
            }
            return [...list].sort((a, b) => {
                if (a.kind !== b.kind) { return a.kind === 'folder' ? -1 : 1; }
                if (this.sort === 'modified') { return (b.modified || '').localeCompare(a.modified || ''); }
                return a.name.localeCompare(b.name);
            });
        },
        get hasSelection() { return this.selected.length > 0; },
        get allVisibleSelected() {
            const visible = this.filtered.map((item) => item.id);
            return visible.length > 0 && visible.every((id) => this.selected.includes(id));
        },
        toggle(id) {
            this.selected = this.selected.includes(id)
                ? this.selected.filter((value) => value !== id)
                : [...this.selected, id];
        },
        toggleAll() {
            this.selected = this.allVisibleSelected ? [] : this.filtered.map((item) => item.id);
        },
        clear() { this.selected = []; },
    }"
    {{ $attributes->merge(['class' => 'overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
>
    {{-- Header / toolbar --}}
    <div class="flex flex-col gap-4 border-b border-gray-200 px-5 py-4 dark:border-gray-800 sm:px-6">
        <div class="flex flex-wrap items-center justify-between gap-3">
            <div class="min-w-0">
                @if ($title)
                    <h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
                @endif

                @if (! empty($breadcrumbs))
                    <nav class="mt-1 flex flex-wrap items-center gap-1 text-sm text-gray-500 dark:text-gray-400" aria-label="Breadcrumb">
                        @foreach ($breadcrumbs as $crumb)
                            @if (! $loop->first)
                                <x-ui.icon name="caret-right" size="xs" class="text-gray-300 dark:text-gray-600" />
                            @endif
                            @if (! empty($crumb['href']) && ! $loop->last)
                                <a href="{{ $crumb['href'] }}" class="rounded px-1 transition hover:text-gray-900 dark:hover:text-white">{{ $crumb['label'] }}</a>
                            @else
                                <span @class(['px-1', 'font-medium text-gray-900 dark:text-white' => $loop->last])>{{ $crumb['label'] }}</span>
                            @endif
                        @endforeach
                    </nav>
                @endif
            </div>

            <div class="flex items-center gap-2">
                @isset($actions)
                    {{ $actions }}
                @endisset
                @if ($uploadHref)
                    <x-ui.button :href="$uploadHref" size="sm" icon="upload-simple">Upload</x-ui.button>
                @endif
            </div>
        </div>

        <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            @if ($search)
                <div class="w-full sm:max-w-xs">
                    <x-ui.input type="search" size="sm" icon="magnifying-glass" placeholder="Search files..." x-model="query" />
                </div>
            @else
                <div></div>
            @endif

            <div class="flex items-center gap-2">
                <select
                    x-model="sort"
                    class="rounded-lg border border-gray-200 bg-white py-1.5 pl-3 pr-8 text-sm text-gray-700 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
                    aria-label="Sort files"
                >
                    <option value="name">Name</option>
                    <option value="modified">Last modified</option>
                </select>

                <div class="inline-flex rounded-lg border border-gray-200 p-0.5 dark:border-gray-700">
                    <button
                        type="button"
                        @click="view = 'grid'"
                        :class="view === 'grid' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'"
                        class="rounded-md p-1.5 transition"
                        aria-label="Grid view"
                    >
                        <x-ui.icon name="squares-four" size="base" />
                    </button>
                    <button
                        type="button"
                        @click="view = 'list'"
                        :class="view === 'list' ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'"
                        class="rounded-md p-1.5 transition"
                        aria-label="List view"
                    >
                        <x-ui.icon name="list" size="base" />
                    </button>
                </div>
            </div>
        </div>
    </div>

    {{-- Selection action bar --}}
    @if ($selectable)
        <div
            x-show="hasSelection"
            x-cloak
            class="flex flex-col gap-3 border-b border-brand-200 bg-brand-50/70 px-5 py-3 text-sm dark:border-brand-900 dark:bg-brand-950/30 sm:flex-row sm:items-center sm:justify-between sm:px-6"
        >
            <div class="font-medium text-brand-800 dark:text-brand-200">
                <span x-text="selected.length"></span> selected
            </div>
            <div class="flex items-center gap-1">
                <x-ui.button type="button" variant="ghost" size="sm" icon="download-simple">Download</x-ui.button>
                <x-ui.button type="button" variant="ghost" size="sm" icon="folder-open">Move</x-ui.button>
                <x-ui.button type="button" variant="ghost" size="sm" icon="trash" class="text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40">Delete</x-ui.button>
                <button type="button" @click="clear()" class="ml-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-700 transition hover:bg-brand-100 dark:text-brand-300 dark:hover:bg-brand-900/40">Clear</button>
            </div>
        </div>
    @endif

    @if ($hasItems)
        {{-- Grid view --}}
        <div x-show="view === 'grid'" class="grid grid-cols-2 gap-3 p-4 sm:grid-cols-3 sm:p-5 lg:grid-cols-4">
            <template x-for="item in filtered" :key="item.id">
                <div
                    @click="selectable && toggle(item.id)"
                    :class="selected.includes(item.id)
                        ? 'border-brand-500 ring-2 ring-brand-500/20'
                        : 'border-gray-200 hover:border-gray-300 dark:border-gray-800 dark:hover:border-gray-700'"
                    class="group relative flex cursor-pointer flex-col gap-3 rounded-xl border bg-white p-3 transition dark:bg-gray-900"
                >
                    <template x-if="selectable">
                        <input
                            type="checkbox"
                            :checked="selected.includes(item.id)"
                            @click.stop="toggle(item.id)"
                            class="absolute left-2 top-2 z-10 rounded border-gray-300 text-brand-600 opacity-0 transition focus:ring-brand-500 group-hover:opacity-100 dark:border-gray-600 dark:bg-gray-900"
                            :class="selected.includes(item.id) ? 'opacity-100' : ''"
                            aria-label="Select file"
                        >
                    </template>

                    <div class="flex aspect-[4/3] items-center justify-center overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-800/60">
                        <template x-if="item.thumbnail">
                            <img :src="item.thumbnail" :alt="item.name" class="h-full w-full object-cover">
                        </template>
                        <template x-if="! item.thumbnail">
                            <i class="ph-fill text-4xl leading-none" :class="['ph-' + item.icon, item.color]" aria-hidden="true"></i>
                        </template>
                    </div>

                    <div class="min-w-0">
                        <p class="truncate text-sm font-medium text-gray-800 dark:text-gray-100" x-text="item.name"></p>
                        <p class="mt-0.5 truncate text-xs text-gray-400 dark:text-gray-500">
                            <span x-show="item.kind === 'folder'" x-text="(item.count ?? 0) + ' items'"></span>
                            <span x-show="item.kind !== 'folder'" x-text="item.size || ''"></span>
                        </p>
                    </div>
                </div>
            </template>
        </div>

        {{-- List view --}}
        <div x-show="view === 'list'" x-cloak class="overflow-x-auto">
            <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
                <caption class="sr-only">{{ $title }} file list</caption>
                <thead class="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
                    <tr>
                        @if ($selectable)
                            <th scope="col" class="w-12 px-5 py-3">
                                <input
                                    type="checkbox"
                                    class="rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-700 dark:bg-gray-900"
                                    :checked="allVisibleSelected"
                                    @change="toggleAll()"
                                    aria-label="Select all files"
                                >
                            </th>
                        @endif
                        <th scope="col" class="px-5 py-3">Name</th>
                        <th scope="col" class="px-5 py-3">Size</th>
                        <th scope="col" class="hidden px-5 py-3 sm:table-cell">Modified</th>
                    </tr>
                </thead>
                <tbody class="divide-y divide-gray-200 dark:divide-gray-800">
                    <template x-for="item in filtered" :key="item.id">
                        <tr
                            :class="selected.includes(item.id) ? 'bg-brand-50/60 dark:bg-brand-950/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800/40'"
                            class="transition"
                        >
                            <template x-if="selectable">
                                <td class="w-12 px-5 py-3">
                                    <input
                                        type="checkbox"
                                        :checked="selected.includes(item.id)"
                                        @change="toggle(item.id)"
                                        class="rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-700 dark:bg-gray-900"
                                        aria-label="Select file"
                                    >
                                </td>
                            </template>
                            <td class="px-5 py-3">
                                <div class="flex items-center gap-3">
                                    <i class="ph-fill text-xl leading-none" :class="['ph-' + item.icon, item.color]" aria-hidden="true"></i>
                                    <span class="truncate text-sm font-medium text-gray-800 dark:text-gray-100" x-text="item.name"></span>
                                </div>
                            </td>
                            <td class="px-5 py-3 text-sm text-gray-500 dark:text-gray-400">
                                <span x-show="item.kind === 'folder'" x-text="(item.count ?? 0) + ' items'"></span>
                                <span x-show="item.kind !== 'folder'" x-text="item.size || '—'"></span>
                            </td>
                            <td class="hidden px-5 py-3 text-sm text-gray-500 dark:text-gray-400 sm:table-cell" x-text="item.modified || '—'"></td>
                        </tr>
                    </template>
                </tbody>
            </table>
        </div>

        {{-- No search results --}}
        <div x-show="filtered.length === 0" x-cloak>
            <x-ui.empty-state icon="magnifying-glass" title="No matches" description="No files match your search." class="border-0 shadow-none" />
        </div>
    @else
        <x-ui.empty-state icon="folder-open" :title="$emptyTitle" :description="$emptyDescription" class="border-0 shadow-none">
            @isset($emptyActions)
                <x-slot:actions>{{ $emptyActions }}</x-slot:actions>
            @endisset
        </x-ui.empty-state>
    @endif

    {{-- Storage usage --}}
    @if ($usage)
        <div class="border-t border-gray-200 px-5 py-4 dark:border-gray-800 sm:px-6">
            <div class="mb-1.5 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
                <span class="font-medium text-gray-700 dark:text-gray-300">Storage</span>
                <span class="tabular-nums">{{ $usage['used'] ?? '' }}@if (! empty($usage['total'])) of {{ $usage['total'] }}@endif</span>
            </div>
            <x-ui.progress :value="$usage['percent'] ?? 0" size="sm" :variant="($usage['percent'] ?? 0) >= 90 ? 'red' : 'brand'" />
        </div>
    @endif
</section>