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
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Actions;
use App\Models\VaultNode;
class GetPathFromVaultNode
{
public function handle(VaultNode $node): string
{
$path =
'vaults' . DIRECTORY_SEPARATOR .
auth()->user()->id . DIRECTORY_SEPARATOR .
$node->vault_id . DIRECTORY_SEPARATOR .
$node->id . '.' . $node->extension;
return $path;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Actions;
use App\Models\VaultNode;
class GetUrlFromVaultNode
{
public function handle(VaultNode $node): string
{
$path = $node->ancestorsAndSelf()->get()->last()->full_path;
$url = '/files/' . $node->vault_id . '?path=' . $path . '.' . $node->extension;
return $url;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Actions;
use App\Models\VaultNode;
use Illuminate\Support\Str;
class GetVaultNodeFromPath
{
public function handle(int $vaultId, string $path, ?int $parentId = null): VaultNode | null
{
$path = Str::ltrim($path, '/');
$pieces = explode('/', $path);
if (count($pieces) == 1) {
$pathParts = pathinfo($pieces[0]);
$node = VaultNode::query()
->where('vault_id', $vaultId)
->where('parent_id', $parentId)
->where('is_file', true)
->where('name', 'LIKE', $pathParts['filename'])
->where('extension', 'LIKE', $pathParts['extension'])
->first();
return $node;
}
$node = VaultNode::query()
->where('vault_id', $vaultId)
->where('parent_id', $parentId)
->where('is_file', false)
->where('name', 'LIKE', $pieces[0])
->first();
$path = Str::after($path, '/');
return $this->handle($vaultId, $path, $node->id);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Actions;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Str;
use GuzzleHttp\Psr7\UriResolver;
class ResolveTwoPaths
{
public function handle(string $currentPath, string $path): string
{
$uri = Utils::uriFor(trim($path));
$resolvedUri = UriResolver::resolve(Utils::uriFor(trim($currentPath)), $uri);
return Str::ltrim($resolvedUri, '/');
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Livewire\Forms;
use Livewire\Form;
use App\Models\Vault;
use App\Models\VaultNode;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
class VaultNodeForm extends Form
{
public Vault $vault;
public ?VaultNode $node = null;
public $parent_id = null;
public $is_file = true;
#[Validate]
public $name = '';
public $extension = null;
public $content = null;
public function rules(): array
{
return [
'name' => [
'required',
'min:3',
'regex:/^[\s\w.-]+$/',
Rule::unique('vault_nodes')
->where('vault_id', $this->vault->id)
->where('parent_id', $this->parent_id)
->ignore($this->node),
],
];
}
public function setVault(Vault $vault): void
{
$this->vault = $vault;
}
public function setNode(VaultNode $node): void
{
$this->node = $node;
$this->parent_id = $node->parent_id;
$this->is_file = $node->is_file;
$this->name = $node->name;
$this->extension = $node->extension;
$this->content = $node->content;
}
public function create(): void
{
$this->validate();
$this->name = Str::trim($this->name);
$this->vault->nodes()->create([
'parent_id' => $this->parent_id,
'is_file' => $this->is_file,
'name' => $this->name,
'extension' => $this->is_file ? 'md' : null,
'content' => $this->content,
]);
$this->reset(['name']);
}
public function update(): void
{
$this->validate();
$this->name = Str::trim($this->name);
$this->node->update([
'parent_id' => $this->parent_id,
'name' => $this->name,
'content' => $this->content,
]);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Vault;
use App\Models\VaultNode;
use Livewire\Attributes\On;
use App\Livewire\Forms\VaultNodeForm;
class AddNode extends Modal
{
public VaultNodeForm $form;
public bool $show = false;
public function mount(Vault $vault): void
{
$this->authorize('update', $vault);
$this->form->setVault($vault);
}
#[On('open-modal')]
public function open(?VaultNode $parent = null, bool $isFile = true): void
{
if (!is_null($parent->vault)) {
$this->authorize('update', $parent->vault);
}
$this->form->parent_id = $parent->id;
$this->form->is_file = $isFile;
$this->openModal();
}
public function add(): void
{
$this->form->create();
$this->closeModal();
$this->dispatch('node-updated');
}
public function render()
{
return view('livewire.modals.addNode');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Vault;
use App\Models\VaultNode;
use Livewire\Attributes\On;
use App\Livewire\Forms\VaultNodeForm;
class EditNode extends Modal
{
public VaultNodeForm $form;
public bool $show = false;
public function mount(Vault $vault): void
{
$this->authorize('update', $vault);
$this->form->setVault($vault);
}
#[On('open-modal')]
public function open(VaultNode $node): void
{
$this->authorize('update', $node->vault);
$this->form->setNode($node);
$this->openModal();
}
public function edit(): void
{
$this->form->update();
$this->closeModal();
$this->dispatch('node-updated');
$this->dispatch('file-refresh', node: $this->form->node);
}
public function render()
{
return view('livewire.modals.editNode');
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Vault;
use App\Models\VaultNode;
use Livewire\Attributes\On;
use App\Livewire\Forms\VaultNodeForm;
use Staudenmeir\LaravelAdjacencyList\Eloquent\Collection;
class SearchNode extends Modal
{
public VaultNodeForm $form;
public Vault $vault;
public string $search = '';
public Collection $nodes;
public bool $show = false;
public function mount(Vault $vault): void
{
$this->authorize('view', $vault);
$this->vault = $vault;
$this->form->setVault($vault);
}
#[On('open-modal')]
public function open(): void
{
$this->openModal();
}
public function search(): void
{
if ($this->search === '') {
$this->nodes = VaultNode::query()
->where('vault_id', $this->vault->id)
->where('is_file', true)
->orderByDesc('updated_at')
->limit(5)
->get();
} else {
$this->nodes = VaultNode::query()
->where('vault_id', $this->vault->id)
->where('is_file', true)
->where('name', 'like', '%' . $this->search . '%')
->orderByDesc('updated_at')
->limit(5)
->get();
}
$this->nodes->transform(function (VaultNode $item) {
$item->full_path = $item->ancestorsAndSelf()->get()->last()->full_path;
return $item;
});
}
public function render()
{
$this->search();
return view('livewire.modals.searchNode');
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Livewire\Vault;
use App\Models\Vault;
use Livewire\Component;
use App\Models\VaultNode;
use Livewire\Attributes\On;
use App\Actions\ResolveTwoPaths;
use App\Livewire\Forms\VaultForm;
use App\Actions\GetPathFromVaultNode;
use App\Actions\GetUrlFromVaultNode;
use App\Actions\GetVaultNodeFromPath;
use App\Livewire\Forms\VaultNodeForm;
class Show extends Component
{
public Vault $vault;
public VaultForm $form;
public VaultNodeForm $nodeForm;
public ?int $selectedFile = null;
public ?string $selectedFilePath = null;
public bool $isEditMode = true;
public bool $showEditModal = false;
public function mount(Vault $vault): void
{
$this->authorize('view', $vault);
$this->vault = $vault;
$this->form->setVault($this->vault);
$this->nodeForm->setVault($this->vault);
}
#[On('file-open')]
public function openFile(VaultNode $node): void
{
$this->authorize('view', $node->vault);
if ($node->vault != $this->vault || !$node->is_file) {
return;
}
$this->selectedFile = $node->id;
$this->selectedFilePath = (new GetUrlFromVaultNode())->handle($node);
$this->nodeForm->setNode($node);
if ($node->extension == 'md') {
$this->dispatch('file-render-markup');
} else {
$this->reset('isEditMode');
}
}
#[On('file-path-open')]
public function openFilePath(string $path): void
{
$currentPath = $this->nodeForm->node->ancestorsAndSelf()->get()->last()->full_path;
$resolvedPath = (new ResolveTwoPaths())->handle($currentPath, $path);
$node = (new GetVaultNodeFromPath())->handle($this->vault->id, $resolvedPath);
if (is_null($node)) {
abort(404);
}
$this->openFile($node);
}
#[On('file-refresh')]
public function refreshFile(VaultNode $node): void
{
$this->authorize('view', $node->vault);
if ($node->id != $this->selectedFile) {
return;
}
$this->selectedFile = $node->id;
$this->selectedFilePath = (new GetPathFromVaultNode())->handle($node);
$this->nodeForm->setNode($node);
}
public function closeFile(): void
{
$this->reset(['selectedFile', 'selectedFilePath']);
}
public function update(): void
{
$this->authorize('update', $this->vault);
$this->validate();
$this->form->update();
$this->vault->refresh();
$this->reset('showEditModal');
}
public function updated(): void
{
$this->nodeForm->update();
if ($this->nodeForm->node->wasChanged(['parent_id', 'name'])) {
$this->dispatch('node-updated');
}
}
public function render()
{
return view('livewire.vault.show');
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Vault;
use App\Models\Vault;
use Livewire\Component;
use App\Models\VaultNode;
use Livewire\Attributes\On;
use Illuminate\Support\Facades\DB;
use App\Actions\GetPathFromVaultNode;
use App\Livewire\Forms\VaultNodeForm;
use Illuminate\Support\Facades\Storage;
#[On('node-updated')]
class TreeView extends Component
{
public Vault $vault;
public VaultNodeForm $nodeForm;
public $showEditModal = false;
public function deleteNode(VaultNode $node): void
{
$this->authorize('delete', $node->vault);
DB::beginTransaction();
try {
if ($node->is_file) {
$this->deleteFile($node);
} else {
$this->deleteFolder($node);
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
}
}
private function deleteFile(VaultNode $node): void
{
if ($node->extension !== 'md') {
$relativePath = (new GetPathFromVaultNode())->handle($node);
Storage::disk('local')->delete($relativePath);
}
$node->delete();
}
private function deleteFolder(VaultNode $node): void
{
foreach ($node->childs as $child) {
if ($child->is_file) {
$this->deleteFile($child);
} else {
$this->deleteFolder($child);
}
}
$node->delete();
}
public function render()
{
$constraint = function ($query) {
$query->whereNull('parent_id')->where('vault_id', $this->vault->id);
};
$nodes = VaultNode::treeOf($constraint)->orderBy('is_file')->orderBy('name')->get()->toTree();
return view('livewire.vault.treeView', [
'nodes' => $nodes,
]);
}
}
@@ -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>
+2
View File
@@ -5,10 +5,12 @@ use App\Livewire\Auth\Register;
use App\Livewire\Auth\ResetPassword;
use App\Livewire\Auth\ForgotPassword;
use Illuminate\Support\Facades\Route;
use App\Livewire\Vault\Show as VaultShow;
use App\Livewire\Vault\Index as VaultIndex;
Route::middleware('auth')->group(function () {
Route::get('vaults', VaultIndex::class)->name('vaults.index');
Route::get('vaults/{vault}', VaultShow::class)->name('vaults.show');
});
Route::middleware('guest')->group(function () {