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>