mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add authors pages and requestType notifications
Introduce full authors browsing/detail feature and enhance notifications to support type-specific titles. - Add server APIs: authors search, author detail, and author books routes (audnexus integration) that require auth and enrich results with library matches. - Add frontend pages/components: /authors listing and /authors/[asin] detail pages; AuthorCard, AuthorGrid, AuthorDetailCard, SimilarAuthorsRow, and related skeletons. - Add hook and integration stubs: new useAuthors hook and audnexus-authors integration; update audible service to expose audibleBaseUrl. - Update AudiobookDetailsModal to use audibleBaseUrl and link author names to author detail pages. - Add header navigation link to Authors. - Notifications: extend docs and code to include requestType (audiobook|ebook), add getEventTitle/getEventMeta helpers, update queue signature and providers/processors/tests to pass/handle requestType so titles can be resolved per request type. - Misc: job queue, processors, provider tests and notification tests updated to reflect new behavior. This change enables browsing authors and provides type-aware notification titles without per-provider changes.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
|
||||
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
|
||||
@@ -71,7 +72,7 @@ export function AudiobookDetailsModal({
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||
const { createRequest, isLoading: isRequesting } = useCreateRequest();
|
||||
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
|
||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||
@@ -286,7 +287,20 @@ export function AudiobookDetailsModal({
|
||||
{audiobook.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300">
|
||||
{audiobook.author}
|
||||
{audiobook.authorAsin ? (
|
||||
<Link
|
||||
href={`/authors/${audiobook.authorAsin}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
{audiobook.author}
|
||||
</Link>
|
||||
) : (
|
||||
audiobook.author
|
||||
)}
|
||||
</p>
|
||||
{audiobook.narrator && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
@@ -418,7 +432,7 @@ export function AudiobookDetailsModal({
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Source</p>
|
||||
<a
|
||||
href={`https://www.audible.com/pd/${asin}`}
|
||||
href={`${audibleBaseUrl}/pd/${asin}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Component: Author Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Premium circular portrait design - distinguishes authors from audiobook covers.
|
||||
* Hover effects and typography match the AudiobookCard aesthetic.
|
||||
* Clicking navigates to the author's detail page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Author } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface AuthorCardProps {
|
||||
author: Author;
|
||||
}
|
||||
|
||||
export function AuthorCard({ author }: AuthorCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/authors/${author.asin}`}
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 rounded-2xl"
|
||||
aria-label={`View details for ${author.name}`}
|
||||
>
|
||||
{/* Circular Portrait Container */}
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="
|
||||
relative overflow-hidden rounded-full
|
||||
w-full aspect-square
|
||||
shadow-lg shadow-black/20 dark:shadow-black/40
|
||||
group-hover:shadow-xl group-hover:shadow-black/25 dark:group-hover:shadow-black/50
|
||||
transform group-hover:scale-[1.04] group-hover:-translate-y-1
|
||||
transition-all duration-300 ease-out
|
||||
"
|
||||
>
|
||||
{author.image ? (
|
||||
<Image
|
||||
src={author.image}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle hover overlay */}
|
||||
<div className="
|
||||
absolute inset-0 rounded-full
|
||||
bg-black/0 group-hover:bg-black/10
|
||||
transition-colors duration-300
|
||||
" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Info */}
|
||||
<div className="mt-3 px-1 text-center">
|
||||
<h3 className="font-semibold text-[15px] leading-snug text-gray-900 dark:text-gray-100 line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
|
||||
{author.name}
|
||||
</h3>
|
||||
|
||||
{/* Genre Pills */}
|
||||
{author.genres.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap justify-center gap-1">
|
||||
{author.genres.map(genre => (
|
||||
<span
|
||||
key={genre}
|
||||
className="inline-block px-2 py-0.5 text-[11px] font-medium rounded-full bg-gray-100 dark:bg-gray-700/60 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Component: Author Detail Card
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Hero section for the author detail page with circular portrait,
|
||||
* name, collapsible biography, and genre pills.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { AuthorDetail } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface AuthorDetailCardProps {
|
||||
author: AuthorDetail;
|
||||
}
|
||||
|
||||
export function AuthorDetailCard({ author }: AuthorDetailCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasLongDescription = (author.description?.length || 0) > 300;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Circular Portrait */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40">
|
||||
{author.image ? (
|
||||
<Image
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-blue-400 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author Info */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{author.name}
|
||||
</h1>
|
||||
|
||||
{/* Genre Pills */}
|
||||
{author.genres.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-2">
|
||||
{author.genres.map(genre => (
|
||||
<span
|
||||
key={genre}
|
||||
className="inline-block px-3 py-1 text-xs font-medium rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{author.description && (
|
||||
<div className="mt-4">
|
||||
<p
|
||||
className={`text-sm sm:text-base text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line ${
|
||||
!expanded && hasLongDescription ? 'line-clamp-4' : ''
|
||||
}`}
|
||||
>
|
||||
{author.description}
|
||||
</p>
|
||||
{hasLongDescription && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="mt-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthorDetailSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-8">
|
||||
{/* Portrait skeleton */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-36 h-36 sm:w-44 sm:h-44 lg:w-52 lg:h-52 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
|
||||
<div className="w-full h-full rounded-full relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info skeleton */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left space-y-4">
|
||||
<div className="h-9 bg-gray-200 dark:bg-gray-700 rounded-lg w-64 mx-auto sm:mx-0" />
|
||||
<div className="flex gap-2 justify-center sm:justify-start">
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-24 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Component: Author Grid
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Premium grid layout for author cards with loading skeletons and empty state.
|
||||
* Mirrors AudiobookGrid patterns with author-appropriate column counts.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AuthorCard } from './AuthorCard';
|
||||
import { Author } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface AuthorGridProps {
|
||||
authors: Author[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
cardSize?: number;
|
||||
}
|
||||
|
||||
// Authors use wider spacing since circular portraits need room to breathe.
|
||||
// Slightly fewer columns than AudiobookGrid at each breakpoint since circles
|
||||
// are visually wider than 2:3 portrait covers.
|
||||
function getGridClasses(size: number): string {
|
||||
const sizeMap: Record<number, string> = {
|
||||
1: 'grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
|
||||
2: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||
3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
|
||||
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6',
|
||||
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
7: 'grid-cols-2 md:grid-cols-3',
|
||||
8: 'grid-cols-2',
|
||||
9: 'grid-cols-1',
|
||||
};
|
||||
return sizeMap[size] || sizeMap[5];
|
||||
}
|
||||
|
||||
export function AuthorGrid({
|
||||
authors,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No authors found',
|
||||
cardSize = 5,
|
||||
}: AuthorGridProps) {
|
||||
const gridClasses = getGridClasses(cardSize);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<AuthorSkeletonCard key={i} index={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authors.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6">
|
||||
<svg className="w-10 h-10 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridClasses} gap-5 sm:gap-6 lg:gap-8`}>
|
||||
{authors.map(author => (
|
||||
<AuthorCard key={author.asin} author={author} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorSkeletonCard({ index = 0 }: { index?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Circular portrait skeleton */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative overflow-hidden rounded-full w-full aspect-square bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text skeleton */}
|
||||
<div className="mt-3 px-1 flex flex-col items-center space-y-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-lg w-4/5" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-lg w-3/5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Component: Similar Authors Row
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Horizontal scrollable carousel of similar author cards.
|
||||
* Desktop: left/right nav arrows. Mobile: drag-to-scroll.
|
||||
* Each card navigates to the author's detail page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SimilarAuthor } from '@/lib/hooks/useAuthors';
|
||||
|
||||
interface SimilarAuthorsRowProps {
|
||||
authors: SimilarAuthor[];
|
||||
currentAuthorName?: string;
|
||||
}
|
||||
|
||||
export function SimilarAuthorsRow({ authors, currentAuthorName }: SimilarAuthorsRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('scroll', checkScroll, { passive: true });
|
||||
const observer = new ResizeObserver(checkScroll);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [checkScroll, authors]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const scrollAmount = el.clientWidth * 0.7;
|
||||
el.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
if (authors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Similar Authors
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({authors.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
{/* Left arrow */}
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 sm:gap-5 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{authors.map(author => (
|
||||
<Link
|
||||
key={author.asin}
|
||||
href={`/authors/${author.asin}${currentAuthorName ? `?from=${encodeURIComponent(currentAuthorName)}` : ''}`}
|
||||
className="flex-shrink-0 w-24 sm:w-28 group/card outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-xl"
|
||||
>
|
||||
{/* Circular portrait */}
|
||||
<div className="relative w-24 h-24 sm:w-28 sm:h-28 rounded-full overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300">
|
||||
{author.image ? (
|
||||
<Image
|
||||
src={author.image}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="112px"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-blue-400 dark:text-blue-300">
|
||||
{author.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<p className="mt-2 text-xs sm:text-sm font-medium text-center text-gray-700 dark:text-gray-300 line-clamp-2 group-hover/card:text-indigo-600 dark:group-hover/card:text-indigo-400 transition-colors">
|
||||
{author.name}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right arrow */}
|
||||
{canScrollRight && (
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-3 z-10 w-10 h-10 bg-white dark:bg-gray-800 rounded-full shadow-lg items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fade edges */}
|
||||
{canScrollLeft && (
|
||||
<div className="hidden md:block absolute left-0 top-0 bottom-2 w-8 bg-gradient-to-r from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<div className="hidden md:block absolute right-0 top-0 bottom-2 w-8 bg-gradient-to-l from-white dark:from-gray-900 to-transparent pointer-events-none z-[5]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimilarAuthorsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-6 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
<div className="h-7 w-40 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-4 sm:gap-5 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-24 sm:w-28" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
</div>
|
||||
<div className="mt-2 h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5 mx-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,6 +160,12 @@ export function Header() {
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
{showBookDate && (
|
||||
<Link
|
||||
href="/bookdate"
|
||||
@@ -264,6 +270,13 @@ export function Header() {
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
<Link
|
||||
href="/authors"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
Authors
|
||||
</Link>
|
||||
{showBookDate && (
|
||||
<Link
|
||||
href="/bookdate"
|
||||
|
||||
Reference in New Issue
Block a user