feat: copy bibtex citations rather than downloading them

Close #3141
This commit is contained in:
George Cushen committed 2025-08-17 22:16:26 +01:00
1 parent 9c48341ee8
commit 0c41b518dc
16 files changed
+1376 -11

No files matched your search

@@ -0,0 +1,70 @@
# Hugo Blox Tailwind CSS v4 Structure
## Overview
The CSS architecture is modularized for maintainability and organization. The entry point is `main.css` which orchestrates all imports.
## Directory Structure
```
css/
├── main.css # Main entry point
├── color-utilities.css # HB Theme Engine
├── config/ # Tailwind configuration
│ ├── tailwind.css # Tailwind v4 directives (@source, @custom-variant)
│ ├── theme.css # HB Theme variables (@theme block)
│ └── safelist.css # Dynamic classes safelist
├── framework/ # Hugo Blox framework styles
│ ├── base.css # Base styles and typography
│ └── components.css # Framework components
├── components/ # Hugo Blox module components
│ └── all.css # Imports all component files
├── blox/ # Block-specific styles
│ └── all.css # Imports all block styles
└── views/ # Listing view styles
└── all.css # Imports all listing view styles
```
## Key Files
### main.css
The orchestrator that imports everything in the correct order:
1. Tailwind base
2. Configuration
3. Base styles
4. Hugo Blox theme engine
5. Components
6. Safelist
### config/tailwind.css
Contains Tailwind v4-specific directives:
- `@source` - Where to scan for classes
- `@custom-variant` - Custom variants (dark, hover)
- `@plugin` - Plugin imports
### config/theme.css
Defines the theme using `@theme` block:
- Color palettes (primary, secondary, neutral)
- Font families and sizes
- Custom properties
### color-utilities.css
Hugo Blox theme engine with color utility classes:
- All color shades (50-950)
- All variants (hover, dark, focus)
- Gradient utilities
## Tailwind v4 Notes
- `@source`, `@theme`, `@plugin` are new v4 directives
- Linters may show warnings for these - they can be ignored
- Hugo's `css.TailwindCSS` function processes this automatically
- No PostCSS configuration needed anymore
## Maintenance
When adding new styles:
1. Framework classes → `framework/components.css`
2. Theme variables → `config/theme.css`
3. Component styles → `components/[file].css`
4. Dynamic classes → Add to `config/safelist.css`
@@ -0,0 +1 @@
@import "attachments.css";
@@ -0,0 +1,189 @@
/**
* Citation clipboard handler for Hugo Blox Builder
* Copies BibTeX citation to clipboard when cite button is clicked
*/
import { hugoEnvironment, i18n } from '@params';
import { showNotification } from './hb-notifier.js';
import { copyToClipboardSync, ClipboardCache } from './hb-clipboard.js';
// Debug mode based on environment
const isDebugMode = hugoEnvironment === 'development';
// Cache for citation content
const citationCache = new ClipboardCache();
// Initialize citation handlers when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCitation);
} else {
initializeCitation();
}
function initializeCitation() {
// Handle citation button clicks using event delegation
document.addEventListener('click', handleCiteClick);
// Prefetch citations on hover/focus for better UX and Safari compatibility
document.addEventListener('mouseover', prefetchOnHover);
document.addEventListener('focusin', prefetchOnHover);
// Prefetch all citations on page load
prefetchAllCitations();
}
/**
* Prefetch all citations on page load
*/
function prefetchAllCitations() {
const citeButtons = document.querySelectorAll('.js-cite-clipboard[data-filename]');
citeButtons.forEach(button => {
const filename = button.getAttribute('data-filename');
if (filename && !citationCache.has(filename)) {
// Fetch in background, don't await
fetchAndCacheCitation(filename);
}
});
}
/**
* Prefetch citation on hover/focus
* @param {Event} e - Hover or focus event
*/
function prefetchOnHover(e) {
const citeButton = e.target.closest('.js-cite-clipboard');
if (!citeButton) return;
const filename = citeButton.getAttribute('data-filename');
if (filename && !citationCache.has(filename)) {
// Fetch in background, don't await
fetchAndCacheCitation(filename);
}
}
/**
* Fetch and cache citation content
* @param {string} filename - Citation file URL
* @returns {Promise<string|null>} - Citation content or null if failed
*/
async function fetchAndCacheCitation(filename) {
try {
const response = await fetch(filename);
if (!response.ok) {
throw new Error(`Failed to fetch citation: ${response.statusText}`);
}
const citation = await response.text();
citationCache.set(filename, citation);
return citation;
} catch (error) {
if (isDebugMode) {
console.error(`Failed to fetch citation ${filename}:`, error);
}
return null;
}
}
/**
* Handle cite button clicks - synchronous clipboard write for Safari compatibility
* @param {Event} e - Click event
*/
function handleCiteClick(e) {
// Check if clicked element or its parent is a cite button
const citeButton = e.target.closest('.js-cite-clipboard');
if (!citeButton) return;
e.preventDefault();
e.stopPropagation();
const filename = citeButton.getAttribute('data-filename');
if (!filename) {
if (isDebugMode) {
console.error('No filename specified for citation');
}
showNotification('Citation file not found', 'error');
return;
}
// Check if citation is cached
const cachedCitation = citationCache.get(filename);
if (cachedCitation) {
// Citation is cached, copy immediately (synchronous for Safari)
copyToClipboardSync(cachedCitation).then(success => {
if (success) {
showNotification(i18n?.copied || 'Citation copied!', 'success');
updateButtonText(citeButton);
} else {
showNotification('Failed to copy citation', 'error');
}
});
} else {
// Not cached, need to fetch first (will fail in Safari with strict mode)
fetchAndCopyWithFallback(filename, citeButton);
}
}
/**
* Fetch and copy with fallback (for browsers that allow async clipboard)
* @param {string} filename - Citation file URL
* @param {HTMLElement} button - Cite button element
*/
async function fetchAndCopyWithFallback(filename, button) {
try {
const citation = await fetchAndCacheCitation(filename);
if (citation) {
// Try to copy (might fail in Safari due to lost user activation)
const success = await copyToClipboardSync(citation);
if (success) {
showNotification(i18n?.copied || 'Citation copied!', 'success');
updateButtonText(button);
} else {
showNotification('Failed to copy citation', 'error');
}
} else {
showNotification('Failed to load citation', 'error');
}
} catch (error) {
if (isDebugMode) {
console.error('Failed to copy citation:', error);
}
// If it's a NotAllowedError, suggest hovering first
if (error.name === 'NotAllowedError') {
showNotification('Please hover over the button first, then click', 'info');
} else {
showNotification('Failed to copy citation', 'error');
}
}
}
/**
* Update button text to show copied state
* @param {HTMLElement} button - The cite button element
*/
function updateButtonText(button) {
const copiedText = i18n?.copied || 'Copied!';
// Find text element to update (skip icon)
const textElement = button.querySelector('span');
if (!textElement) {
if (isDebugMode) {
console.warn('Could not find text element in cite button');
}
return;
}
const originalText = textElement.textContent;
textElement.textContent = copiedText;
// Add visual feedback
button.classList.add('opacity-70');
// Revert after 2 seconds
setTimeout(() => {
textElement.textContent = originalText;
button.classList.remove('opacity-70');
}, 2000);
}
// Export functions for potential reuse
export { handleCiteClick, prefetchAllCitations };
@@ -0,0 +1,186 @@
/**
* Clipboard Utilities for Hugo Blox Builder
* Provides cross-browser clipboard functionality
*/
import { hugoEnvironment } from '@params';
// Debug mode based on environment
const isDebugMode = hugoEnvironment === 'development';
/**
* Copy text to clipboard with multiple fallback methods
* @param {string} text - Text to copy
* @returns {Promise<boolean>} - Whether the copy was successful
*/
export async function copyToClipboard(text) {
// Method 1: Modern Clipboard API
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
if (isDebugMode) {
console.log('Copied using Clipboard API');
}
return true;
} catch (err) {
if (isDebugMode) {
console.warn('Clipboard API failed:', err);
}
}
}
// Method 2: execCommand fallback
return copyUsingExecCommand(text);
}
/**
* Copy text to clipboard synchronously (Safari-compatible)
* @param {string} text - Text to copy
* @returns {Promise<boolean>} - Whether the copy was successful
*/
export function copyToClipboardSync(text) {
return new Promise((resolve) => {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text)
.then(() => {
if (isDebugMode) {
console.log('Copied using Clipboard API (sync)');
}
resolve(true);
})
.catch(err => {
if (isDebugMode) {
console.warn('Clipboard API failed:', err);
}
// Fallback to execCommand
resolve(copyUsingExecCommand(text));
});
} else {
// Use execCommand directly
resolve(copyUsingExecCommand(text));
}
});
}
/**
* Legacy copy method using execCommand
* @param {string} text - Text to copy
* @returns {boolean} - Success status
*/
export function copyUsingExecCommand(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
// Make it invisible but copyable
Object.assign(textarea.style, {
position: 'fixed',
top: '0',
left: '0',
width: '2em',
height: '2em',
padding: '0',
border: 'none',
outline: 'none',
boxShadow: 'none',
background: 'transparent',
fontSize: '16px', // Prevent zoom on iOS
});
document.body.appendChild(textarea);
// Select the text
textarea.focus();
textarea.select();
// For iOS
if (navigator.userAgent.match(/ipad|iphone/i)) {
const range = document.createRange();
range.selectNodeContents(textarea);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
textarea.setSelectionRange(0, 999999);
}
let success = false;
try {
success = document.execCommand('copy');
if (isDebugMode) {
console.log(`execCommand copy ${success ? 'succeeded' : 'failed'}`);
}
} catch (err) {
if (isDebugMode) {
console.error('execCommand failed:', err);
}
}
document.body.removeChild(textarea);
return success;
}
/**
* Test if clipboard API is available and working
* @returns {boolean} - Whether clipboard API is available
*/
export function isClipboardAPIAvailable() {
return !!(navigator.clipboard && window.isSecureContext);
}
/**
* Create a prefetch cache for clipboard content
* Useful for Safari compatibility where content must be copied synchronously
*/
export class ClipboardCache {
constructor() {
this.cache = new Map();
}
/**
* Add content to cache
* @param {string} key - Cache key
* @param {string} content - Content to cache
*/
set(key, content) {
this.cache.set(key, content);
if (isDebugMode) {
console.log(`Cached clipboard content for: ${key}`);
}
}
/**
* Get content from cache
* @param {string} key - Cache key
* @returns {string|null} - Cached content or null
*/
get(key) {
return this.cache.get(key) || null;
}
/**
* Check if content is cached
* @param {string} key - Cache key
* @returns {boolean} - Whether content is cached
*/
has(key) {
return this.cache.has(key);
}
/**
* Clear the cache
*/
clear() {
this.cache.clear();
}
/**
* Get cache size
* @returns {number} - Number of cached items
*/
get size() {
return this.cache.size;
}
}
// Export default clipboard copy function
export default copyToClipboard;
@@ -0,0 +1,194 @@
/**
* Notification/Toast System for Hugo Blox Builder
* Provides reusable notification functionality
*/
import { hugoEnvironment } from '@params';
// Debug mode based on environment
const isDebugMode = hugoEnvironment === 'development';
/**
* Show a notification message using Tailwind styles
* @param {string} message - The message to display
* @param {string} type - The type of notification ('success', 'error', 'info', 'warning')
* @param {number} duration - Duration in milliseconds (default 3000)
* @returns {HTMLElement} - The notification element
*/
export function showNotification(message, type = 'success', duration = 3000) {
// Get or create the notification container
const container = getOrCreateContainer();
// Remove any existing notifications with same message
const existingNotification = container.querySelector('.hb-notification');
if (existingNotification && existingNotification.textContent.includes(message)) {
existingNotification.remove();
}
// Create notification element
const notification = createNotificationElement(message, type);
// Add to container
container.appendChild(notification);
// Handle close button
const closeBtn = notification.querySelector('.hb-notification-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
removeNotification(notification);
});
}
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
removeNotification(notification);
}, duration);
}
return notification;
}
/**
* Get or create the notification container
* @returns {HTMLElement} - The container element
*/
function getOrCreateContainer() {
let container = document.getElementById('hb-notification-container');
if (!container) {
if (isDebugMode) {
console.warn('Notification container not found, creating fallback');
}
container = document.createElement('div');
container.id = 'hb-notification-container';
container.className = 'fixed top-20 right-4 z-[9999] pointer-events-none';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
document.body.appendChild(container);
}
return container;
}
/**
* Create a notification element
* @param {string} message - The message to display
* @param {string} type - The type of notification
* @returns {HTMLElement} - The notification element
*/
function createNotificationElement(message, type) {
const notification = document.createElement('div');
notification.setAttribute('role', 'alert');
// Define colors for different types
const colors = {
success: { bg: '#10b981', bgClass: 'bg-green-500' },
error: { bg: '#ef4444', bgClass: 'bg-red-500' },
info: { bg: '#3b82f6', bgClass: 'bg-blue-500' },
warning: { bg: '#f59e0b', bgClass: 'bg-amber-500' }
};
const color = colors[type] || colors.info;
// Set classes
notification.className = `hb-notification pointer-events-auto flex items-center gap-2 px-4 py-3 text-white rounded-lg shadow-lg ${color.bgClass} animate-slide-in`;
// Add inline styles as fallback
notification.style.cssText = `
background-color: ${color.bg};
color: white;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
margin-bottom: 0.5rem;
pointer-events: auto;
`;
// Add icon based on type
const icons = {
success: '<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>',
error: '<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>',
info: '<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>',
warning: '<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>'
};
const icon = icons[type] || icons.info;
notification.innerHTML = `
${icon}
<span class="text-sm font-medium">${message}</span>
<button class="hb-notification-close ml-2 text-white/80 hover:text-white transition-colors" aria-label="Close">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
`;
// Ensure animation styles exist
ensureAnimationStyles();
return notification;
}
/**
* Remove notification with animation
* @param {HTMLElement} notification - The notification element to remove
*/
export function removeNotification(notification) {
if (!notification || !notification.parentNode) return;
notification.classList.remove('animate-slide-in');
notification.classList.add('animate-slide-out');
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 300);
}
/**
* Ensure animation styles are in the document
*/
function ensureAnimationStyles() {
if (!document.querySelector('#hb-notification-styles')) {
const style = document.createElement('style');
style.id = 'hb-notification-styles';
style.textContent = `
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
.animate-slide-out {
animation: slide-out 0.3s ease-out;
}
`;
document.head.appendChild(style);
}
}
// Export default for convenience
export default showNotification;
@@ -0,0 +1,4 @@
{{/* Notification Container for toasts/alerts */}}
{{/* This container is placed at the page level to ensure proper z-index and visibility */}}
{{/* Used by hb-notifier.js module for showing toast notifications */}}
<div id="hb-notification-container" class="fixed top-20 right-4 z-[9999] pointer-events-none" aria-live="polite" aria-atomic="true"></div>
@@ -32,10 +32,10 @@
{{ end }}
{{ $resource := $page.Resources.GetMatch "cite.bib" }}
{{ with $resource }}
<a class="hb-attachment-link {{ if $is_list }}hb-attachment-link-small{{else}}hb-attachment-link-large{{end}}" href="{{ .RelPermalink }}" target="_blank" data-filename="{{ .RelPermalink }}">
<button class="hb-attachment-link {{ if $is_list }}hb-attachment-link-small{{else}}hb-attachment-link-large{{end}} js-cite-clipboard cursor-pointer" type="button" data-filename="{{ .RelPermalink }}">
{{ partial "functions/get_icon" (dict "name" "document-duplicate" "attributes" "style=\"height: 1em\" class='inline-block'") }}
{{ i18n "btn_cite" }}
</a>
<span>{{ i18n "btn_cite" }}</span>
</button>
{{ end }}
{{ with $page.Params.url_code }}
{{ $resource := $page.Resources.GetMatch . }}
@@ -267,7 +267,10 @@
{{- $js_lang := resources.Get "js/hb-i18n.js" | resources.Minify -}}
{{- $js_nav := resources.Get "js/hb-nav.js" | resources.Minify -}}
{{- $js_sidebar := resources.Get "js/hb-sidebar.js" | resources.Minify -}}
{{ $js_bundle := slice $js_bundle_head $js_academic $js_theme $js_lang $js_nav $js_sidebar | resources.Concat (printf "js/hugo-blox-%s.min.js" .Lang) }}
{{- $js_notifier := resources.Get "js/hb-notifier.js" | js.Build (dict "targetPath" (printf "js/hb-notifier-%s.js" .Lang ) "params" $js_params) | resources.Minify -}}
{{- $js_clipboard := resources.Get "js/hb-clipboard.js" | js.Build (dict "targetPath" (printf "js/hb-clipboard-%s.js" .Lang ) "params" $js_params) | resources.Minify -}}
{{- $js_citation := resources.Get "js/hb-citation.js" | js.Build (dict "targetPath" (printf "js/hb-citation-%s.js" .Lang ) "params" $js_params) | resources.Minify -}}
{{ $js_bundle := slice $js_bundle_head $js_academic $js_theme $js_lang $js_nav $js_sidebar $js_notifier $js_clipboard $js_citation | resources.Concat (printf "js/hugo-blox-%s.min.js" .Lang) }}
{{- if hugo.IsProduction -}}
{{ $js_bundle = $js_bundle | fingerprint "sha256" }}
{{- end -}}
@@ -27,6 +27,9 @@
{{ partial "site_footer" . }}
</div>
{{/* Notification Container for toasts/alerts */}}
{{ partial "notification-container" . }}
{{/* EXTENSIBILITY HOOK: BODY-END */}}
{{ partial "functions/get_hook" (dict "hook" "body-end" "context" .) }}
</body>