Add Vault show page
Add VaultNode CRUD features Add VaultNode search feature Add TreeView component
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 () {
|
||||
|
||||