feat: Introduce Shaarli Professional Theme with a modern sidebar layout and light/dark mode.

This commit is contained in:
Bruno Charest 2026-01-17 15:25:13 -05:00
parent 646f92005f
commit 0b6b45b1d7
3 changed files with 177 additions and 61 deletions

View File

@ -3113,3 +3113,62 @@ select:focus {
.header-action-btn.has-active-filter { .header-action-btn.has-active-filter {
background: rgba(239, 68, 68, 0.2); background: rgba(239, 68, 68, 0.2);
} }
/* Filter Info Banner */
.filter-info-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
margin: 1rem 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.filter-info-banner.empty-results {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.filter-info-content {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.95rem;
}
.filter-info-content i {
font-size: 1.25rem;
opacity: 0.9;
}
.filter-info-content strong {
background: rgba(255, 255, 255, 0.25);
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-weight: 600;
}
.filter-clear-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.4rem 0.75rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: background 0.2s ease;
}
.filter-clear-btn:hover {
background: rgba(255, 255, 255, 0.35);
}
.filter-clear-btn i {
font-size: 1rem;
}

View File

@ -4,8 +4,7 @@
<meta name="referrer" content="same-origin"> <meta name="referrer" content="same-origin">
<link rel="alternate" type="application/rss+xml" href="{$feedurl}?do=rss{$searchcrits}#" title="RSS Feed" /> <link rel="alternate" type="application/rss+xml" href="{$feedurl}?do=rss{$searchcrits}#" title="RSS Feed" />
<link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" /> <link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" />
<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#" <link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#" title="Shaarli search - {$shaarlititle}" />
title="Shaarli search - {$shaarlititle}" />
<!-- Professional Theme CSS --> <!-- Professional Theme CSS -->
<link type="text/css" rel="stylesheet" href="{$asset_path}/css/style.css#" /> <link type="text/css" rel="stylesheet" href="{$asset_path}/css/style.css#" />
@ -26,13 +25,15 @@
{/if} {/if}
<script> <script>
var shaarli = { var shaarli = {
basePath: '{$base_path}', basePath: '{$base_path}',
rootPath: '{$root_path}', rootPath: '{$root_path}',
assetPath: '{$asset_path}', assetPath: '{$asset_path}',
isAuth: {if="$is_logged_in"}true{else}false{/if}, isAuth: {if="$is_logged_in"}true{else}false{/if},
pageName: '{$pageName}' pageName: '{$pageName}',
}; visibility: '{$visibility}',
untaggedonly: {if="$untaggedonly"}true{else}false{/if}
};
</script> </script>
<script src="{$asset_path}/js/script.js#" defer></script> <script src="{$asset_path}/js/script.js#" defer></script>

View File

@ -402,39 +402,70 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// Handle filter toggle switches // Handle filter toggle switches
// Logic:
// - Visibility: all (neither checked), private (only private checked), public (only public checked)
// - Untagged: can be combined with any visibility
function applyFilters() { function applyFilters() {
const isPrivate = filterPrivate?.checked || false; const isPrivate = filterPrivate?.checked || false;
const isPublic = filterPublic?.checked || false; const isPublic = filterPublic?.checked || false;
const isUntagged = filterUntagged?.checked || false; const isUntagged = filterUntagged?.checked || false;
let basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : ''; let basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
let url = basePath + '/';
// Get current filter state from server-side rendered variables
const currentVisibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : '';
const currentUntagged = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) || false;
console.log('[Filter] Applying filters - private:', isPrivate, 'public:', isPublic, 'untagged:', isUntagged); console.log('[Filter] Applying filters - private:', isPrivate, 'public:', isPublic, 'untagged:', isUntagged);
console.log('[Filter] Current state - visibility:', currentVisibility, 'untagged:', currentUntagged);
// Build the URL based on selected filters using the correct Shaarli admin paths let url = basePath + '/';
if (isPrivate && isUntagged) {
url = basePath + '/admin/visibility/private?searchtags='; // Determine desired visibility
} else if (isPublic && isUntagged) { let desiredVisibility = 'all';
url = basePath + '/admin/visibility/public?searchtags='; if (isPrivate) {
} else if (isPrivate) { desiredVisibility = 'private';
url = basePath + '/admin/visibility/private';
} else if (isPublic) { } else if (isPublic) {
url = basePath + '/admin/visibility/public'; desiredVisibility = 'public';
} else if (isUntagged) { }
url = basePath + '/?searchtags=';
// Build URL based on desired state
if (desiredVisibility === 'private') {
url = basePath + '/admin/visibility/private';
} else if (desiredVisibility === 'public') {
url = basePath + '/admin/visibility/public';
} else {
// visibility = all - need to clear visibility if it was set
if (currentVisibility && currentVisibility !== '') {
url = basePath + '/admin/visibility/all';
} else if (isUntagged !== currentUntagged) {
// Untagged state changed - /untagged-only works as toggle
url = basePath + '/untagged-only';
} else {
url = basePath + '/';
}
} }
// else: no filter, url stays as basePath + '/'
console.log('[Filter] Navigating to:', url); console.log('[Filter] Navigating to:', url);
window.location.href = url; window.location.href = url;
} }
// Clear all filters and go to home // Clear all filters - go back to showing all bookmarks
function clearAllFilters() { function clearAllFilters() {
const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : ''; const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
const currentVisibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : '';
const currentUntagged = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) || false;
// If visibility is set, clear it first
if (currentVisibility && currentVisibility !== '') {
window.location.href = basePath + '/admin/visibility/all';
} else if (currentUntagged) {
// Toggle untagged off using /untagged-only
window.location.href = basePath + '/untagged-only';
} else {
window.location.href = basePath + '/'; window.location.href = basePath + '/';
} }
}
// Private and public are mutually exclusive // Private and public are mutually exclusive
filterPrivate?.addEventListener('change', (e) => { filterPrivate?.addEventListener('change', (e) => {
@ -455,24 +486,21 @@ document.addEventListener('DOMContentLoaded', () => {
applyFilters(); applyFilters();
}); });
// Initialize filter states from URL (detect current filter state from page URL) // Initialize filter states from server-side variables (set in includes.html)
(function initFilterStates() { (function initFilterStates() {
console.log('[Filter Debug] ========================================'); console.log('[Filter Debug] ========================================');
// Detect filter state from current URL path // Get filter state from server-side rendered variables
const currentPath = window.location.pathname; const visibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : '';
const currentSearch = window.location.search; const untaggedonly = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) ? shaarli.untaggedonly : false;
// Detect visibility filter from URL path // Detect active filters from server variables
let isPrivateActive = currentPath.includes('/visibility/private') || currentPath.includes('/admin/visibility/private'); let isPrivateActive = visibility === 'private';
let isPublicActive = currentPath.includes('/visibility/public') || currentPath.includes('/admin/visibility/public'); let isPublicActive = visibility === 'public';
let isUntaggedActive = untaggedonly === true;
// Detect untagged filter console.log('[Filter Debug] shaarli.visibility:', visibility);
let isUntaggedActive = currentPath.includes('/untagged-only') || console.log('[Filter Debug] shaarli.untaggedonly:', untaggedonly);
(currentSearch.includes('searchtags=') && !currentSearch.match(/searchtags=[^&]+/));
console.log('[Filter Debug] URL path:', currentPath);
console.log('[Filter Debug] URL search:', currentSearch);
console.log('[Filter Debug] isPrivateActive:', isPrivateActive); console.log('[Filter Debug] isPrivateActive:', isPrivateActive);
console.log('[Filter Debug] isPublicActive:', isPublicActive); console.log('[Filter Debug] isPublicActive:', isPublicActive);
@ -512,45 +540,66 @@ document.addEventListener('DOMContentLoaded', () => {
// Create and display the filter info banner // Create and display the filter info banner
if (hasActiveFilter && !document.getElementById('filter-info-banner')) { if (hasActiveFilter && !document.getElementById('filter-info-banner')) {
// Get result count from page // Get result count from page - try multiple sources
let resultCount = ''; let resultCount = 0;
const pagingStats = document.querySelector('.paging-stats strong:last-child'); const pagingStats = document.querySelector('.paging-stats strong:last-child');
const linkCount = document.querySelectorAll('.link-outer').length;
if (pagingStats) { if (pagingStats) {
resultCount = pagingStats.textContent; resultCount = parseInt(pagingStats.textContent) || 0;
} else if (linkCount > 0) {
resultCount = linkCount;
} }
// Build the message with result count like "X results with status private" // Check if no results (empty state)
let filterParts = []; const emptyState = document.querySelector('.empty-state');
const isEmptyResults = emptyState !== null || resultCount === 0;
// Build the message like the examples:
// "5 results without any tag"
// "5 results with status private without any tag"
// "1 result with status public without any tag"
let statusPart = '';
let untaggedPart = '';
if (isPrivateActive) { if (isPrivateActive) {
filterParts.push('with status <strong>private</strong>'); statusPart = '<strong>private</strong>';
} else if (isPublicActive) { } else if (isPublicActive) {
filterParts.push('with status <strong>public</strong>'); statusPart = '<strong>public</strong>';
} }
if (isUntaggedActive) { if (isUntaggedActive) {
if (filterParts.length > 0) { untaggedPart = '<strong>without any tag</strong>';
filterParts.push('and <strong>untagged</strong>'); }
// Build the message
let message = '';
if (isEmptyResults) {
message = 'Nothing found.';
} else { } else {
filterParts.push('<strong>untagged</strong>'); const resultWord = resultCount === 1 ? 'result' : 'results';
if (statusPart && untaggedPart) {
message = `${resultCount} ${resultWord} with status ${statusPart} ${untaggedPart}`;
} else if (statusPart) {
message = `${resultCount} ${resultWord} with status ${statusPart}`;
} else if (untaggedPart) {
message = `${resultCount} ${resultWord} ${untaggedPart}`;
} }
} }
let message = resultCount ? resultCount + ' results ' + filterParts.join(' ') : 'Showing ' + filterParts.join(' ') + ' links'; console.log('[Filter Debug] resultCount:', resultCount);
console.log('[Filter Debug] isEmptyResults:', isEmptyResults);
console.log('[Filter Debug] filterParts:', filterParts);
console.log('[Filter Debug] message:', message); console.log('[Filter Debug] message:', message);
if (filterParts.length > 0) { if (message) {
// Get base path safely // Get base path safely
const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : ''; const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
const banner = document.createElement('div'); const banner = document.createElement('div');
banner.id = 'filter-info-banner'; banner.id = 'filter-info-banner';
banner.className = 'filter-info-banner'; banner.className = 'filter-info-banner' + (isEmptyResults ? ' empty-results' : '');
banner.innerHTML = ` banner.innerHTML = `
<div class="filter-info-content"> <div class="filter-info-content">
<i class="mdi mdi-filter-variant"></i>
<span>${message}</span> <span>${message}</span>
</div> </div>
<button type="button" class="filter-clear-btn" id="filter-clear-btn" title="Clear filters"> <button type="button" class="filter-clear-btn" id="filter-clear-btn" title="Clear filters">
@ -562,19 +611,18 @@ document.addEventListener('DOMContentLoaded', () => {
// Try to find the best insertion point // Try to find the best insertion point
const contentToolbar = document.querySelector('.content-toolbar'); const contentToolbar = document.querySelector('.content-toolbar');
const linklist = document.getElementById('linklist'); const linklist = document.getElementById('linklist');
const linksList = document.getElementById('links-list'); const emptyStateDiv = document.querySelector('.empty-state');
console.log('[Filter Debug] contentToolbar:', contentToolbar); console.log('[Filter Debug] contentToolbar:', contentToolbar);
console.log('[Filter Debug] linklist:', linklist); console.log('[Filter Debug] linklist:', linklist);
console.log('[Filter Debug] linksList:', linksList);
// Insert after the content-toolbar (pagination) // Insert after the content-toolbar (pagination)
if (contentToolbar && contentToolbar.parentNode) { if (contentToolbar && contentToolbar.parentNode) {
contentToolbar.parentNode.insertBefore(banner, contentToolbar.nextSibling); contentToolbar.parentNode.insertBefore(banner, contentToolbar.nextSibling);
console.log('[Filter Debug] Banner inserted after content-toolbar'); console.log('[Filter Debug] Banner inserted after content-toolbar');
} else if (linksList && linksList.parentNode) { } else if (emptyStateDiv && emptyStateDiv.parentNode) {
linksList.parentNode.insertBefore(banner, linksList); emptyStateDiv.parentNode.insertBefore(banner, emptyStateDiv);
console.log('[Filter Debug] Banner inserted before links-list'); console.log('[Filter Debug] Banner inserted before empty-state');
} else if (linklist) { } else if (linklist) {
linklist.insertBefore(banner, linklist.firstChild); linklist.insertBefore(banner, linklist.firstChild);
console.log('[Filter Debug] Banner inserted at beginning of linklist'); console.log('[Filter Debug] Banner inserted at beginning of linklist');
@ -584,8 +632,16 @@ document.addEventListener('DOMContentLoaded', () => {
// Add click handler to clear button // Add click handler to clear button
document.getElementById('filter-clear-btn')?.addEventListener('click', () => { document.getElementById('filter-clear-btn')?.addEventListener('click', () => {
console.log('[Filter] Clear button clicked, navigating to home'); console.log('[Filter] Clear button clicked, clearing all filters');
// Clear based on what was active
if (isPrivateActive || isPublicActive) {
window.location.href = basePath + '/admin/visibility/all';
} else if (isUntaggedActive) {
// Toggle untagged off
window.location.href = basePath + '/untagged-only';
} else {
window.location.href = basePath + '/'; window.location.href = basePath + '/';
}
}); });
} }
} }