Add Vault show page

Add VaultNode CRUD features
Add VaultNode search feature
Add TreeView component
This commit is contained in:
Bruno
2024-10-04 19:37:43 +01:00
parent 6b319552fc
commit b2b86fe513
32 changed files with 981 additions and 0 deletions
@@ -0,0 +1,4 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 293 B

@@ -0,0 +1,6 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"
stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 507 B

@@ -0,0 +1,4 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m19.5 8.25-7.5 7.5-7.5-7.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 275 B

@@ -0,0 +1,4 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m8.25 4.5 7.5 7.5-7.5 7.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 274 B

@@ -0,0 +1,5 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 327 B

@@ -0,0 +1,6 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 537 B

@@ -0,0 +1,6 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 524 B

@@ -0,0 +1,6 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 10.5v6m3-3H9m4.06-7.19-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 468 B

@@ -0,0 +1,5 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,3 @@
<div class="flex flex-grow w-full">
{{ $slot }}
</div>
@@ -0,0 +1,3 @@
<li x-data="{ accordionOpen: false }" class="items-center justify-between py-0.5" {{ $attributes }}>
{{ $slot }}
</li>
@@ -0,0 +1,33 @@
@props(['node'])
<div class="relative w-full">
<x-menu>
<button x-ref="button" @click="$dispatch('file-open', {'node': {{ $node->id }}})"
@contextmenu.prevent="menuOpen = !menuOpen" @keydown.escape="menuOpen = false"
@auxclick.outside="menuOpen = false" class="flex items-center w-full">
<span class="flex items-center w-full gap-2">
<span title="{{ $node->name }}" class="ml-1 overflow-hidden whitespace-nowrap text-ellipsis">
{{ $node->name }}
</span>
@if ($node->extension !== 'md')
<x-treeView.badge>{{ $node->extension }}</x-treeView.badge>
@endif
</span>
</button>
<x-menu.items>
<x-menu.close>
<x-menu.item @click="$wire.dispatchTo('modals.edit-node', 'open-modal', { node: {{ $node->id }} })">
<x-icons.pencilSquare class="w-4 h-4" />
{{ __('Rename') }}
</x-menu.item>
<x-menu.item wire:confirm="{{ __('Are you sure you want to delete this file?') }}"
wire:click="deleteNode({{ $node->id }})">
<x-icons.trash class="w-4 h-4" />
{{ __('Delete') }}
</x-menu.item>
</x-menu.close>
</x-menu.items>
</x-menu>
</div>
@@ -0,0 +1,45 @@
@props(['node'])
<div class="relative w-full">
<x-menu>
<button x-ref="button" @click="accordionOpen = !accordionOpen" @contextmenu.prevent="menuOpen = !menuOpen"
@keydown.escape="menuOpen = false" @auxclick.outside="menuOpen = false" class="flex items-center w-full">
<span class="flex items-center w-full">
<x-icons.chevronRight x-show="!accordionOpen" class="w-4 h-4" />
<x-icons.chevronDown x-show="accordionOpen" class="w-4 h-4" x-cloak />
<span title="{{ $node->name }}" class="ml-1 overflow-hidden whitespace-nowrap text-ellipsis">
{{ $node->name }}
</span>
</span>
</button>
<x-menu.items>
<x-menu.close>
<x-menu.item
@click="$wire.dispatchTo('modals.add-node', 'open-modal', { parent: {{ $node->id }} })">
<x-icons.documentPlus class="w-4 h-4" />
{{ __('New note') }}
</x-menu.item>
<x-menu.item
@click="$wire.dispatchTo('modals.add-node', 'open-modal', { parent: {{ $node->id }}, isFile: false })">
<x-icons.folderPlus class="w-4 h-4" />
{{ __('New folder') }}
</x-menu.item>
<x-menu.item @click="$wire.dispatchTo('modals.edit-node', 'open-modal', { node: {{ $node->id }} })">
<x-icons.pencilSquare class="w-4 h-4" />
{{ __('Rename') }}
</x-menu.item>
<x-menu.item wire:confirm="{{ __('Are you sure you want to delete this folder?') }}"
wire:click="deleteNode({{ $node->id }})">
<x-icons.trash class="w-4 h-4" />
{{ __('Delete') }}
</x-menu.item>
</x-menu.close>
</x-menu.items>
</x-menu>
</div>
@@ -0,0 +1,8 @@
@props(['root'])
<ul @unless ($root)
x-show="accordionOpen" x-collapse x-cloak
@endunless
class="relative w-full pl-4 first:pl-0">
{{ $slot }}
</ul>
@@ -0,0 +1,7 @@
@props(['nodes', 'root'])
<x-treeView.items :root="$root">
@foreach ($nodes as $node)
<x-vault.treeViewRow :$node />
@endforeach
</x-treeView.items>
@@ -0,0 +1,13 @@
@props(['node'])
<x-treeView.item>
@if (!$node->is_file)
<x-treeView.itemFolder :$node />
@if (!empty($node->children) && $node->children->count())
@include('components.vault.treeViewNode', ['nodes' => $node->children, 'root' => false])
@endif
@else
<x-treeView.itemFile :$node />
@endif
</x-treeView.item>
@@ -0,0 +1,11 @@
<x-modal wire:model="show">
<x-modal.panel title="{{ $form->is_file ? __('New note') : __('New folder') }}">
<x-form wire:submit="add" class="flex flex-col gap-6">
<x-form.input name="form.name" placeholder="{{ __('Name') }}" type="name" required autofocus />
<div class="flex justify-end">
<x-form.submit label="{{ __('Add') }}" target="add" />
</div>
</x-form>
</x-modal.panel>
</x-modal>
@@ -0,0 +1,11 @@
<x-modal wire:model="show">
<x-modal.panel title="{{ $form->is_file ? __('Rename file') : __('Rename folder') }}">
<x-form wire:submit="edit" class="flex flex-col gap-6">
<x-form.input name="form.name" placeholder="{{ __('Name') }}" type="name" required autofocus />
<div class="flex justify-end">
<x-form.submit label="{{ __('Edit') }}" target="edit" />
</div>
</x-form>
</x-modal.panel>
</x-modal>
@@ -0,0 +1,30 @@
<x-modal wire:model="show">
<x-modal.panel title="Search" top>
<input type="text" wire:model.live.debounce.500ms="search" placeholder="{{ __('Search') }}" autofocus
class="block w-full p-2 border rounded-lg bg-light-base-100 dark:bg-base-800 text-light-base-700 dark:text-base-200 focus:ring-0 focus:outline focus:outline-0 border-light-base-300 dark:border-base-500 focus:border-light-base-600 dark:focus:border-base-400" />
<div class="mt-4">
@if (count($nodes))
<ul wire:loading.class="opacity-50">
@foreach ($nodes as $node)
<li>
<button type="button" wire:click="$parent.openFile({{ $node->id }}); modalOpen = false"
class="flex w-full gap-2 py-1 text-left">
<span title="{{ $node->name }}"
class="overflow-hidden whitespace-nowrap text-ellipsis">
{{ $node->full_path }}
</span>
@if ($node->extension !== 'md')
<x-treeView.badge>{{ $node->extension }}</x-treeView.badge>
@endif
</button>
</li>
@endforeach
</ul>
@else
<p>{{ __('No results found') }}</p>
@endif
</div>
</x-modal.panel>
</x-modal>
@@ -0,0 +1,234 @@
<div class="flex flex-col h-dvh">
<x-layouts.appHeader>
<div class="flex items-center gap-4">
<button type="button" @click="$dispatch('sidebar-left-toggle')">
<x-icons.folder class="w-5 h-5" />
</button>
<button type="button" @click="$wire.dispatchTo('modals.search-node', 'open-modal')">
<x-icons.magnifyingGlass class="w-5 h-5" />
</button>
</div>
<div class="flex items-center gap-4">
<livewire:layout.user-menu />
</div>
</x-layouts.appHeader>
<x-layouts.appMain>
<div x-data="{
isSidebarOpen: false,
isEditMode: $wire.entangle('isEditMode'),
selectedFile: $wire.entangle('selectedFile'),
html: '',
toggleEditMode() { this.isEditMode = !this.isEditMode },
}" x-init="$watch('isEditMode', value => html = markdown())
$watch('selectedFile', value => html = markdown())" x-cloak
@sidebar-left-toggle.window="isSidebarOpen = !isSidebarOpen" class="relative flex w-full">
<div wire:loading wire:target.except="nodeForm.name, nodeForm.content"
class="fixed inset-0 z-40 bg-black bg-opacity-40"></div>
<div x-show="false" @click="isSidebarOpen = false" class="fixed inset-0 z-20 bg-black bg-opacity-50"
x-transition:enter="ease-out duration-300" x-transition:leave="ease-in duration-200"></div>
<div class="absolute top-0 left-0 z-[5] flex flex-col h-full overflow-hidden overflow-y-auto transition-all w-60 bg-light-base-200 dark:bg-base-950"
:class="{ 'translate-x-0': isSidebarOpen, '-translate-x-full hidden': !isSidebarOpen }">
<div class="sticky top-0 z-[5] flex justify-between p-4 bg-light-base-200 dark:bg-base-950">
<h3>{{ $vault->name }}</h3>
<div class="flex items-center">
<x-menu>
<x-menu.button>
<x-icons.bars3 class="w-5 h-5" />
</x-menu.button>
<x-menu.items>
<x-menu.close>
<x-menu.item @click="$wire.dispatchTo('modals.add-node', 'open-modal')">
<x-icons.documentPlus class="w-4 h-4" />
{{ __('New note') }}
</x-menu.item>
<x-menu.item
@click="$wire.dispatchTo('modals.add-node', 'open-modal', { isFile: false })">
<x-icons.folderPlus class="w-4 h-4" />
{{ __('New folder') }}
</x-menu.item>
<x-modal wire:model="showEditModal">
<x-modal.open>
<x-menu.item>
<x-icons.pencilSquare class="w-4 h-4" />
{{ __('Edit vault') }}
</x-menu.item>
</x-modal.open>
<x-modal.panel title="{{ __('Edit vault') }}">
<x-form wire:submit="update" class="flex flex-col gap-6">
<x-form.input name="form.name" label="{{ __('Name') }}"
type="name" required autofocus />
<div class="flex justify-end">
<x-form.submit label="{{ __('Edit') }}" target="edit" />
</div>
</x-form>
</x-modal.panel>
</x-modal>
<x-menu.itemLink href="/vaults" wire:navigate>
<x-icons.xMark class="w-4 h-4" />
Close vault
</x-menu.item>
</x-menu.close>
</x-menu.items>
</x-menu>
</div>
</div>
<livewire:vault.tree-view :$vault />
</div>
<div class="absolute top-0 bottom-0 right-0 flex flex-col w-full overflow-y-auto transition-all text-start md:pl-60"
:class="{ 'md:pl-60': isSidebarOpen, '': !isSidebarOpen }">
@if ($selectedFile)
<div class="sticky top-0 z-[5] p-4 bg-light-base-50 dark:bg-base-900">
<div class="flex justify-between">
<input type="text" wire:model.live.debounce.500ms="nodeForm.name"
class="flex flex-grow p-0 text-lg bg-transparent border-0 focus:ring-0 focus:outline-0" />
<div class="flex items-center gap-2">
<span wire:loading.flex wire:target="nodeForm.name, nodeForm.content"
class="flex items-center">
<x-icons.spinner class="w-4 h-4 animate-spin" />
</span>
@if ($nodeForm->extension == 'md')
<button type="button" x-show="isEditMode" @click="toggleEditMode"
title="{{ __('Click to read') }}">
<x-icons.bookOpen class="w-5 h-5" />
</button>
<button type="button" x-show="!isEditMode" @click="toggleEditMode"
title="{{ __('Click to edit') }}">
<x-icons.codeBracket class="w-5 h-5" />
</button>
@endif
<button type="button" wire:click="closeFile" title="{{ __('Close file') }}">
<x-icons.xMark class="w-5 h-5" />
</button>
</div>
</div>
@error('nodeForm.name')
<p class="text-sm text-error-500" aria-live="assertive">{{ $message }}</p>
@enderror
</div>
<div class="flex flex-grow px-4">
@if ($nodeForm->extension == 'md')
<textarea wire:model.live.debounce.500ms="nodeForm.content" x-show="isEditMode" id="noteEdit"
data-id="{{ $selectedFile }}" class="w-full h-full p-0 bg-transparent border-0 focus:ring-0 focus:outline-0"></textarea>
<div x-show="!isEditMode" x-html="html" id="noteView" class="w-full h-full markdown-body">
</div>
@elseif (in_array($nodeForm->extension, ['jpg', 'jpeg', 'png', 'gif']))
<div>
<img src="{{ $selectedFilePath }}" />
</div>
@elseif (in_array($nodeForm->extension, ['pdf']))
<object type="application/pdf" data="{{ $selectedFilePath }}"
class="w-full h-full"></object>
@elseif (in_array($nodeForm->extension, ['webp', 'mp4', 'avi']))
<video class="w-full" controls>
<source src="{{ $selectedFilePath }}" />
{{ __('Your browser does not support the video tag') }}
</video>
@elseif (in_array($nodeForm->extension, ['mp3', 'flac']))
<div class="flex items-start justify-center w-full">
<audio class="w-full" controls>
<source src="{{ $selectedFilePath }}">
{{ __('Your browser does not support the audio tag') }}
</audio>
</div>
@endif
</div>
@else
<div class="flex items-center justify-center w-full h-full gap-2">
<x-form.button @click="$wire.dispatchTo('modals.search-node', 'open-modal')">
<x-icons.magnifyingGlass class="w-4 h-4" />
<span class="hidden text-sm font-medium md:block">{{ __('Open file') }}</span>
</x-form.button>
<x-form.button primary @click="$wire.dispatchTo('modals.add-node', 'open-modal')">
<x-icons.plus class="w-4 h-4" />
<span class="hidden text-sm font-medium md:block">{{ __('New note') }}</span>
</x-form.button>
</div>
@endif
</div>
</div>
</x-layouts.appMain>
<livewire:modals.add-node :$vault />
<livewire:modals.edit-node :$vault />
<livewire:modals.search-node :$vault />
</div>
@assets
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
@endassets
<script>
let markedRender = new marked.Renderer;
markedRender.parser = new marked.Parser;
let renderListitem = markedRender.listitem.bind(markedRender);
function markdown() {
let el = document.getElementById('noteEdit');
let markdown = '';
if (!el) {
return markdown;
}
renderer = {
image(token) {
// external images
if (token.href.startsWith('http://') || token.href.startsWith('https://')) {
return '<img src="' + token.href + '" alt="' + token.text + '" />';
}
// internal images
return '<img src="/files/{{ $vault->id }}?path=' + token.href + '&node=' + node + '" alt="' +
token.text + '" />';
},
link(token) {
// external links
if (token.href.startsWith('http://') || token.href.startsWith('https://')) {
return '<a href="' + token.href + '" target="_blank">' + token.text + '</a>';
}
// internal links
return '<a href="" wire:click.prevent="openFilePath(\'' + token.href + '\')">' + token.text +
'</a>';
},
listitem(token) {
let html = renderListitem(token);
if (token.task) {
html = html.replace('<li>', '<li class="task-list-item">');
html = html.replace('<input ', '<input class="task-list-item-checkbox" ');
}
return html;
},
};
marked.use({
renderer
});
markdown = marked.parse(el.value);
return markdown;
}
</script>
@@ -0,0 +1,9 @@
<div class="flex flex-grow px-4">
<x-treeView>
@if (count($nodes))
@include('components.vault.treeViewNode', ['nodes' => $nodes, 'root' => true])
@else
<p>{{ __('Your vault is empty.') }}</p>
@endif
</x-treeView>
</div>