final folder#show polish

This commit is contained in:
Andrew Brown 2025-06-11 23:58:48 -07:00
parent c931356f22
commit a0ddca75bf

View File

@ -2,7 +2,7 @@
showNewFolderModal: false,
showEditFolderModal: false,
sidebarOpen: false,
showContentSidebar: false,
showContentSidebar: <%= @child_folders.any? || @content.any? %>,
viewMode: 'grid',
searchQuery: ''
}" class="h-screen flex bg-gray-50">
@ -207,8 +207,60 @@
<!-- Main Content Area -->
<main class="flex-1 flex flex-col overflow-hidden lg:pt-0 pt-16" :class="{ 'lg:ml-0': !showContentSidebar }">
<!-- Breadcrumb Navigation -->
<nav class="bg-white border-b border-gray-200 px-6 lg:px-8 py-3">
<ol class="flex items-center space-x-2 text-sm">
<%
breadcrumb_folders = []
current_folder = @folder
while current_folder&.parent_folder
breadcrumb_folders.unshift(current_folder.parent_folder)
current_folder = current_folder.parent_folder
end
%>
<li>
<%
context_class = @folder.context.constantize rescue Document
if context_class.column_names.include?('folder_id')
context_path = context_class.name.downcase.pluralize + "_path"
context_name = @folder.context.pluralize
else
context_path = "documents_path"
context_name = "Documents"
end
%>
<%= link_to send(context_path), class: "text-gray-500 hover:text-gray-700 hover:underline" do %>
<%= context_name %>
<% end %>
</li>
<li class="text-gray-400">
<i class="material-icons text-sm">chevron_right</i>
</li>
<% breadcrumb_folders.each do |folder| %>
<li>
<%= link_to folder.title, folder, class: "text-gray-500 hover:text-gray-700 hover:underline truncate max-w-[150px]" %>
</li>
<li class="text-gray-400">
<i class="material-icons text-sm">chevron_right</i>
</li>
<% end %>
<li class="font-medium text-gray-900 truncate max-w-[200px]">
<%= @folder.title %>
</li>
</ol>
</nav>
<!-- Header Section -->
<div class="bg-white shadow-sm">
<div class="relative">
<%= image_tag asset_path("card-headers/folders.jpg"), class: 'h-32 w-full object-cover lg:h-48' %>
<div class="absolute inset-0 bg-gradient-to-r from-black/40 to-black/20"></div>
</div>
<div class="px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
@ -248,9 +300,13 @@
<div class="flex items-center">
<button
@click="showEditFolderModal = true"
class="w-12 h-12 rounded-lg <%= Folder.color %> flex items-center justify-center mr-3"
class="w-12 h-12 rounded-lg <%= Folder.color %> flex items-center justify-center mr-3 hover:shadow-lg transition-shadow duration-200 group relative"
title="Edit folder"
>
<i class="material-icons text-white text-xl"><%= Folder.icon %></i>
<span class="absolute -bottom-2 -right-2 w-6 h-6 bg-white rounded-full shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<i class="material-icons text-gray-600 text-sm">edit</i>
</span>
</button>
<div>
<h1 class="text-2xl font-bold text-gray-900"><%= @folder.title %></h1>
@ -266,15 +322,39 @@
</div>
<!-- Action Buttons -->
<div>
<div class="flex items-center space-x-3">
<button
@click="showNewFolderModal = true"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg text-white <%= Folder.color %> hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg text-white <%= Folder.color %> hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm transition-all duration-150 hover:shadow-md"
>
<i class="material-icons text-lg mr-2">create_new_folder</i>
<span class="hidden sm:inline">New subfolder</span>
<span class="sm:hidden">New</span>
</button>
<div class="relative" data-controller="dropdown">
<button
data-action="dropdown#toggle click@window->dropdown#hide"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm transition-all duration-150"
>
<i class="material-icons text-lg mr-2">more_horiz</i>
<span class="hidden sm:inline">Actions</span>
</button>
<div data-dropdown-target="menu" class="hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 py-1 z-10">
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="material-icons text-sm mr-2 align-text-bottom">drive_file_move</i>
Move folder
</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="material-icons text-sm mr-2 align-text-bottom">content_copy</i>
Duplicate folder
</a>
<a href="#" class="block px-4 py-2 text-sm text-red-600 hover:bg-red-50">
<i class="material-icons text-sm mr-2 align-text-bottom">delete</i>
Delete folder
</a>
</div>
</div>
</div>
</div>
</div>
@ -286,8 +366,8 @@
<h2 class="text-lg font-medium text-gray-900 mb-4">Folders</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
<% @child_folders.each do |folder| %>
<%= link_to folder, class: "bg-white rounded-lg border border-gray-200 hover:shadow-md transition-shadow duration-200 p-4 flex items-center" do %>
<div class="w-10 h-10 rounded-lg <%= Folder.color %> flex items-center justify-center mr-3 flex-shrink-0">
<%= link_to folder, class: "bg-white rounded-lg border border-gray-200 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 p-4 flex items-center group" do %>
<div class="w-10 h-10 rounded-lg <%= Folder.color %> flex items-center justify-center mr-3 flex-shrink-0 group-hover:shadow-md transition-shadow">
<i class="material-icons text-white"><%= Folder.icon %></i>
</div>
<div class="flex-1 min-w-0">
@ -313,7 +393,7 @@
<p class="text-sm text-gray-500">Empty folder</p>
<% end %>
</div>
<i class="material-icons text-gray-400">chevron_right</i>
<i class="material-icons text-gray-400 group-hover:text-gray-600 transition-colors">chevron_right</i>
<% end %>
<% end %>
</div>
@ -322,7 +402,10 @@
<!-- Content Section -->
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Contents <span class="text-gray-500"><%= @content.count %></span></h2>
<div class="flex items-center space-x-3">
<h2 class="text-lg font-medium text-gray-900">Contents</h2>
<span class="text-sm font-medium px-2.5 py-0.5 rounded-full bg-gray-100 text-gray-800"><%= @content.count %></span>
</div>
<!-- Search and Filter Controls -->
<% if @content.any? %>
@ -331,12 +414,26 @@
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="material-icons text-gray-400 text-lg">search</i>
</div>
<input
x-model="searchQuery"
type="text"
placeholder="Search contents..."
class="block w-64 pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<div class="relative group" x-data="{ isSearching: false }">
<input
x-model="searchQuery"
@input="isSearching = true; setTimeout(() => { isSearching = false }, 300)"
type="text"
placeholder="Search contents..."
class="block w-64 pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
/>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
<button x-show="searchQuery.length > 0" @click="searchQuery = ''" class="text-gray-400 hover:text-gray-600">
<i class="material-icons text-lg">clear</i>
</button>
<div x-show="isSearching" class="animate-spin h-4 w-4 border-2 border-blue-500 rounded-full border-t-transparent"></div>
</div>
<!-- Keyboard shortcut hint -->
<div class="absolute top-full left-0 mt-2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10">
Press <kbd class="px-1 py-0.5 bg-gray-700 rounded">⌘K</kbd> to focus search
</div>
</div>
</div>
<!-- View Mode Toggle -->
@ -353,12 +450,56 @@
</div>
<% if @content.any? %>
<!-- Search Empty State -->
<div x-show="searchQuery !== ''" x-transition class="hidden" id="search-empty-state">
<div class="flex-1 flex items-center justify-center p-12 min-h-96">
<div class="text-center max-w-lg">
<div class="mx-auto h-24 w-24 rounded-full bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center mb-6 shadow-sm">
<i class="material-icons text-4xl text-gray-400">search_off</i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-3">No matching items found</h3>
<p class="text-gray-600 mb-8 leading-relaxed" x-text="`We couldn't find any items matching '${searchQuery}'. Try adjusting your search terms.`"></p>
<div class="flex justify-center">
<button @click="searchQuery = ''" class="inline-flex items-center px-6 py-3 text-sm font-medium rounded-lg text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors shadow-sm">
<i class="material-icons text-lg mr-2">clear</i>
Clear search
</button>
</div>
</div>
</div>
</div>
<!-- Grid View -->
<div x-show="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<% @content.each do |content_item| %>
<div
class="group relative"
x-show="searchQuery === '' || '<%= content_item.name.downcase %>'.includes(searchQuery.toLowerCase()) || '<%= content_item.respond_to?(:page_type) ? content_item.page_type.downcase : content_item.class.name.downcase %>'.includes(searchQuery.toLowerCase())"
x-data="{
isFavorite: <%= content_item.respond_to?(:favorite?) ? content_item.favorite? : false %>,
isLoading: false,
async toggleFavorite(event) {
if (!this.canFavorite) return;
event.preventDefault();
event.stopPropagation();
this.isLoading = true;
try {
const result = await window.toggleItemFavorite('<%= content_item.id %>', '<%= content_item.respond_to?(:page_type) ? content_item.page_type.downcase.pluralize : content_item.class.name.downcase.pluralize %>');
if (result.success) {
this.isFavorite = !this.isFavorite;
} else {
alert('Error toggling favorite. Please try again.');
}
} catch (error) {
console.error('Error:', error);
alert('An unexpected error occurred. Please check the console.');
} finally {
this.isLoading = false;
}
},
canFavorite: <%= content_item.respond_to?(:favorite?) ? 'true' : 'false' %>
}"
>
<%= link_to (content_item.respond_to?(:view_path) ? content_item.view_path : content_item), class: "block" do %>
<div class="bg-white hover:shadow-xl rounded-lg transition-shadow duration-200 overflow-hidden">
@ -389,6 +530,17 @@
</div>
</div>
<% end %>
<% if content_item.respond_to?(:favorite?) %>
<button
@click="toggleFavorite"
:disabled="isLoading"
class="absolute top-4 right-4 group p-1 text-xl rounded-lg bg-white/80 backdrop-blur-sm hover:bg-notebook-blue hover:shadow-md focus:outline-none focus:ring-2 focus:ring-yellow-500 transition-all"
:class="{ 'opacity-50 cursor-not-allowed': isLoading }"
title="Toggle favorite"
>
<i class="material-icons" :class="isFavorite ? 'text-yellow-400' : 'text-gray-400 group-hover:text-yellow-400'" x-text="isFavorite ? 'star' : 'star_outline'"></i>
</button>
<% end %>
</div>
<% end %>
</div>
@ -397,8 +549,32 @@
<div x-show="viewMode === 'list'" style="display: none;" class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<% @content.each_with_index do |content_item, index| %>
<div
class="<%= 'border-t border-gray-200' if index > 0 %> hover:bg-gray-50 transition-colors"
class="<%= 'border-t border-gray-200' if index > 0 %> hover:bg-gray-50 transition-colors relative"
x-show="searchQuery === '' || '<%= content_item.name.downcase %>'.includes(searchQuery.toLowerCase()) || '<%= content_item.respond_to?(:page_type) ? content_item.page_type.downcase : content_item.class.name.downcase %>'.includes(searchQuery.toLowerCase())"
x-data="{
isFavorite: <%= content_item.respond_to?(:favorite?) ? content_item.favorite? : false %>,
isLoading: false,
async toggleFavorite(event) {
if (!this.canFavorite) return;
event.preventDefault();
event.stopPropagation();
this.isLoading = true;
try {
const result = await window.toggleItemFavorite('<%= content_item.id %>', '<%= content_item.respond_to?(:page_type) ? content_item.page_type.downcase.pluralize : content_item.class.name.downcase.pluralize %>');
if (result.success) {
this.isFavorite = !this.isFavorite;
} else {
alert('Error toggling favorite. Please try again.');
}
} catch (error) {
console.error('Error:', error);
alert('An unexpected error occurred. Please check the console.');
} finally {
this.isLoading = false;
}
},
canFavorite: <%= content_item.respond_to?(:favorite?) ? 'true' : 'false' %>
}"
>
<%= link_to (content_item.respond_to?(:view_path) ? content_item.view_path : content_item), class: "flex items-center p-4 space-x-4" do %>
<div class="flex-shrink-0">
@ -424,21 +600,32 @@
<i class="material-icons text-gray-400">chevron_right</i>
</div>
<% end %>
<% if content_item.respond_to?(:favorite?) %>
<button
@click="toggleFavorite"
:disabled="isLoading"
class="absolute right-12 top-1/2 transform -translate-y-1/2 p-1 text-lg rounded-full hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-yellow-500"
:class="{ 'opacity-50 cursor-not-allowed': isLoading }"
title="Toggle favorite"
>
<i class="material-icons" :class="isFavorite ? 'text-yellow-400' : 'text-gray-300 hover:text-gray-400'" x-text="isFavorite ? 'star' : 'star_outline'"></i>
</button>
<% end %>
</div>
<% end %>
</div>
<% else %>
<!-- Empty State -->
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center">
<div class="mx-auto h-16 w-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<i class="material-icons text-2xl text-gray-400">folder_open</i>
<div class="bg-white rounded-lg border border-gray-200 p-8 text-center shadow-sm hover:shadow-md transition-shadow">
<div class="mx-auto h-24 w-24 rounded-full bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center mb-6 shadow-sm">
<i class="material-icons text-4xl text-gray-400">folder_open</i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">This folder is empty</h3>
<p class="text-gray-500 mb-4">Add content to this folder by selecting "Move to folder" when viewing any page.</p>
<div class="flex flex-wrap justify-center gap-3">
<h3 class="text-xl font-semibold text-gray-900 mb-3">This folder is empty</h3>
<p class="text-gray-600 mb-6 max-w-md mx-auto">Add content to this folder by selecting "Move to folder" when viewing any page, or create new content directly.</p>
<div class="flex flex-wrap justify-center gap-3 max-w-lg mx-auto">
<% Rails.application.config.content_types[:free].each do |content_type| %>
<%= link_to new_polymorphic_path(content_type) do %>
<button class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg text-white <%= content_type.color %> hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<button class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg text-white <%= content_type.color %> hover:opacity-90 hover:shadow focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm transition-all duration-200">
<i class="material-icons text-sm mr-2"><%= content_type.icon %></i>
New <%= content_type.name %>
</button>
@ -458,4 +645,103 @@
<div x-show="showEditFolderModal">
<%= render partial: 'folders/tailwind_edit_modal', locals: { folder: @folder } %>
</div>
<!-- Enhanced JavaScript -->
<script>
// Global function for toggling favorites
window.toggleItemFavorite = async function(contentId, contentClass) {
const csrfToken = document.querySelector('meta[name=csrf-token]').getAttribute('content');
let postUrl = contentClass === 'documents'
? `/documents/${contentId}/toggle_favorite`
: `/plan/${contentClass}/${contentId}/toggle_favorite`;
try {
let response = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ id: contentId })
});
if (!response.ok && postUrl.startsWith('/plan/')) {
postUrl = `/${contentClass}/${contentId}/toggle_favorite`;
response = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ id: contentId })
});
}
return { success: response.ok };
} catch (error) {
console.error('Fetch error:', error);
throw error;
}
};
document.addEventListener('DOMContentLoaded', function() {
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Only if not typing in an input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// CMD/Ctrl + K to focus search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
const searchInput = document.querySelector('input[x-model="searchQuery"]');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
// CMD/Ctrl + \ to toggle content sidebar
if ((e.metaKey || e.ctrlKey) && e.key === '\\') {
e.preventDefault();
const toggleButton = document.querySelector('[\\@click="showContentSidebar = !showContentSidebar"]');
if (toggleButton) toggleButton.click();
}
// 1 for grid view, 2 for list view
if (e.key === '1') {
e.preventDefault();
window.Alpine && window.Alpine.evaluate(document.querySelector('[x-data]'), 'viewMode = "grid"');
}
if (e.key === '2') {
e.preventDefault();
window.Alpine && window.Alpine.evaluate(document.querySelector('[x-data]'), 'viewMode = "list"');
}
});
// Search empty state management
function updateSearchEmptyState() {
const searchInput = document.querySelector('input[x-model="searchQuery"]');
const searchEmptyState = document.getElementById('search-empty-state');
if (!searchInput || !searchEmptyState) return;
const searchQuery = searchInput.value.trim();
const visibleItems = document.querySelectorAll('[x-show*="searchQuery"]:not([style*="display: none"])');
if (searchQuery && visibleItems.length === 0) {
searchEmptyState.style.display = 'block';
} else {
searchEmptyState.style.display = 'none';
}
}
// Watch for search input changes
const searchInput = document.querySelector('input[x-model="searchQuery"]');
if (searchInput) {
searchInput.addEventListener('input', () => {
setTimeout(updateSearchEmptyState, 100); // Small delay to let Alpine.js update visibility
});
}
});
</script>
</div>