Implement ID-based routing and folder auto-generation from titles

Major features:
- Switch from slug-based to ID-based routing (/documents/123)
- Enable title editing with automatic slug/path regeneration
- Auto-generate folder structure from title slashes (e.g., Laravel/Livewire/Components)
- Persist sidebar folder open/close state using localStorage
- Remove slug unique constraint (ID routing makes it unnecessary)
- Implement recursive tree view with multi-level folder support

Architecture changes:
- DocumentService: Add generatePathAndSlug() for title-based path generation
- Routes: Change from {document:slug} to {document} for ID binding
- SidebarTree: Extract recursive rendering to partials/tree-item.blade.php
- Database: Remove unique constraint from documents.slug column

UI improvements:
- Display only last path component in sidebar (Components vs Laravel/Livewire/Components)
- Folder state persists across page navigation via localStorage
- Title field accepts slashes for folder organization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-29 09:41:38 +09:00
commit 6e7f8566ef
140 changed files with 40590 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Livewire;
use App\Models\Document;
use App\Services\DocumentService;
use Livewire\Component;
use Livewire\Attributes\Computed;
class QuickSwitcher extends Component
{
public $search = '';
public $selectedIndex = 0;
#[Computed]
public function results()
{
if (empty($this->search)) {
return Document::select('id', 'title', 'slug', 'path', 'updated_at')
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
}
// FULLTEXT検索を使用日本語対応
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
->whereRaw('MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', [$this->search])
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
// FULLTEXT検索で結果がない場合は LIKE 検索にフォールバック
if (empty($results)) {
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
->where(function($query) {
$query->where('title', 'like', '%' . $this->search . '%')
->orWhere('content', 'like', '%' . $this->search . '%');
})
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
}
return $results;
}
public function updated($propertyName)
{
if ($propertyName === 'search') {
$this->selectedIndex = 0;
}
}
public function selectNext()
{
$results = $this->results;
if ($this->selectedIndex < count($results) - 1) {
$this->selectedIndex++;
}
}
public function selectPrevious()
{
if ($this->selectedIndex > 0) {
$this->selectedIndex--;
}
}
public function selectDocument()
{
$results = $this->results;
if (isset($results[$this->selectedIndex])) {
$document = $results[$this->selectedIndex];
// id が存在することを確認
if (!empty($document['id'])) {
return $this->redirect(route('documents.show', $document['id']));
}
}
}
public function render()
{
return view('livewire.quick-switcher');
}
}