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,163 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? config('app.name', 'Knowledge Base') }}</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script>document.addEventListener('livewire:navigated', function() { hljs.highlightAll(); });</script>
<style>
pre code.hljs {
background: #1e1e1e !important; /* VSCode dark と同じ */
color: #dcdcdc !important;
padding: 1rem;
border-radius: 8px;
display: block;
overflow-x: auto;
}
</style>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
@stack('styles')
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-xl font-semibold text-gray-900">
{{ config('app.name', 'Knowledge Base') }}
</h1>
</div>
<div class="flex items-center space-x-4">
<!-- Quick Switcher Trigger -->
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click.prevent="console.log('Button clicked'); $dispatch('open-quick-switcher')"
>
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Quick Switch
<kbd class="ml-2 px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">
Ctrl+K
</kbd>
</button>
@auth
<!-- User Dropdown -->
<div x-data="{ open: false }" @click.away="open = false" class="relative">
<button
@click="open = !open"
class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
>
{{ Auth::user()->name }}
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
<div
x-show="open"
x-transition
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5"
>
<a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Profile
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Logout
</button>
</form>
</div>
</div>
@else
<a href="{{ route('login') }}" class="text-sm text-gray-700 hover:text-gray-900">
Login
</a>
@endauth
</div>
</div>
</div>
</header>
<!-- Main Content -->
<div class="flex h-[calc(100vh-4rem)]">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
@livewire('sidebar-tree')
</aside>
<!-- Main Panel -->
<main class="flex-1 overflow-y-auto">
{{ $slot }}
</main>
</div>
</div>
<!-- Quick Switcher Modal -->
@livewire('quick-switcher')
@livewireScripts
<!-- Global Keyboard Shortcuts -->
<script>
document.addEventListener('keydown', function(e) {
// Ctrl+K or Cmd+K for quick switcher
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
window.dispatchEvent(new CustomEvent('open-quick-switcher'));
}
});
// Sidebar folder state management
document.addEventListener('alpine:init', () => {
Alpine.data('sidebarState', () => ({
expandedFolders: [],
initExpandedFolders() {
// Load from localStorage
const stored = localStorage.getItem('kb_expanded_folders');
if (stored) {
try {
this.expandedFolders = JSON.parse(stored);
} catch (e) {
this.expandedFolders = [];
}
}
},
toggleFolder(path) {
const index = this.expandedFolders.indexOf(path);
if (index > -1) {
this.expandedFolders.splice(index, 1);
} else {
this.expandedFolders.push(path);
}
// Save to localStorage
localStorage.setItem('kb_expanded_folders', JSON.stringify(this.expandedFolders));
},
isFolderExpanded(path) {
return this.expandedFolders.includes(path);
}
}));
});
</script>
@stack('scripts')
</body>
</html>