Add square cover toggle and UI support

Introduce a SquareCoversToggle component and wire cover-aspect switching throughout the app. PreferencesContext now stores and persists a new squareCovers flag (with cross-tab sync), and pages (Home, Search) expose the toggle and pass the squareCovers prop to AudiobookGrid/AudiobookCard. AudiobookCard/Grid and skeletons were updated to respect square vs 2:3 aspect ratios and include smoother transitions. Also update app icons/manifest references to RMAB_1024x1024_ICON.png and make header/branding responsive (truncate titles, adjust version badge placement and logo usage). Minor UI/UX tweaks added for accessibility and visual polish.
This commit is contained in:
kikootwo
2026-02-04 19:50:39 -05:00
parent 1cb77dc989
commit fe39831ada
10 changed files with 138 additions and 28 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+1 -1
View File
@@ -8,7 +8,7 @@
"theme_color": "#1e3a5f",
"icons": [
{
"src": "/RMAB_1024x1024.png",
"src": "/RMAB_1024x1024_ICON.png",
"sizes": "1024x1024",
"type": "image/png",
"purpose": "any maskable"
+1 -1
View File
@@ -30,7 +30,7 @@ export const metadata: Metadata = {
],
shortcut: "/rmab_icon.ico",
apple: [
{ url: "/RMAB_1024x1024.png", sizes: "1024x1024", type: "image/png" },
{ url: "/RMAB_1024x1024_ICON.png", sizes: "1024x1024", type: "image/png" },
],
},
appleWebApp: {
+10 -5
View File
@@ -12,12 +12,13 @@ import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { StickyPagination } from '@/components/ui/StickyPagination';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function HomePage() {
const [popularPage, setPopularPage] = useState(1);
const [newReleasesPage, setNewReleasesPage] = useState(1);
const { cardSize, setCardSize } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
// Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null);
@@ -62,10 +63,11 @@ export default function HomePage() {
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
Popular Audiobooks
</h2>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
@@ -89,6 +91,7 @@ export default function HomePage() {
isLoading={loadingPopular}
emptyMessage="No popular audiobooks available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
)}
</div>
@@ -101,10 +104,11 @@ export default function HomePage() {
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
New Releases
</h2>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
@@ -128,6 +132,7 @@ export default function HomePage() {
isLoading={loadingNewReleases}
emptyMessage="No new releases available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
)}
</div>
+7 -4
View File
@@ -11,13 +11,14 @@ import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useSearch } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [page, setPage] = useState(1);
const { cardSize, setCardSize } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
// Debounce search query
useEffect(() => {
@@ -109,15 +110,16 @@ export default function SearchPage() {
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Search Results
</h2>
{!isLoading && totalResults > 0 && (
<span className="text-sm text-gray-600 dark:text-gray-400">
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''})
</span>
)}
<div className="ml-auto">
<div className="ml-auto flex items-center gap-1">
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={setCardSize} />
</div>
</div>
@@ -130,6 +132,7 @@ export default function SearchPage() {
isLoading={!!(isLoading && page === 1)}
emptyMessage={`No results found for "${debouncedQuery}"`}
cardSize={cardSize}
squareCovers={squareCovers}
/>
{/* Load More */}
+6 -2
View File
@@ -19,6 +19,7 @@ interface AudiobookCardProps {
isRequested?: boolean;
requestStatus?: string;
onRequestSuccess?: () => void;
squareCovers?: boolean;
}
export function AudiobookCard({
@@ -26,6 +27,7 @@ export function AudiobookCard({
isRequested = false,
requestStatus,
onRequestSuccess,
squareCovers = false,
}: AudiobookCardProps) {
const { user } = useAuth();
const { createRequest, isLoading } = useCreateRequest();
@@ -59,10 +61,12 @@ export function AudiobookCard({
return (
<>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-all duration-300">
{/* Cover Art - Clickable */}
<div
className="relative aspect-[2/3] bg-gray-200 dark:bg-gray-700 cursor-pointer group"
className={`relative bg-gray-200 dark:bg-gray-700 cursor-pointer group overflow-hidden ${
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
}`}
onClick={() => setShowModal(true)}
>
{audiobook.coverArtUrl ? (
+10 -5
View File
@@ -15,6 +15,7 @@ interface AudiobookGridProps {
emptyMessage?: string;
onRequestSuccess?: () => void;
cardSize?: number; // 1-9, default 5
squareCovers?: boolean; // true = square (1:1), false = rectangle (2:3)
}
// Helper function to get grid classes based on card size
@@ -41,6 +42,7 @@ export function AudiobookGrid({
emptyMessage = 'No audiobooks found',
onRequestSuccess,
cardSize = 5,
squareCovers = false,
}: AudiobookGridProps) {
const gridClasses = getGridClasses(cardSize);
@@ -48,7 +50,7 @@ export function AudiobookGrid({
return (
<div className={`grid ${gridClasses} gap-3 sm:gap-4 md:gap-6`}>
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
<SkeletonCard key={i} squareCovers={squareCovers} />
))}
</div>
);
@@ -76,23 +78,26 @@ export function AudiobookGrid({
}
return (
<div className={`grid ${gridClasses} gap-3 sm:gap-4 md:gap-6`}>
<div className={`grid ${gridClasses} gap-3 sm:gap-4 md:gap-6 transition-all duration-300`}>
{audiobooks.map((audiobook) => (
<AudiobookCard
key={audiobook.asin}
audiobook={audiobook}
onRequestSuccess={onRequestSuccess}
squareCovers={squareCovers}
/>
))}
</div>
);
}
function SkeletonCard() {
function SkeletonCard({ squareCovers = false }: { squareCovers?: boolean }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden animate-pulse transition-all duration-300">
{/* Cover Art Skeleton */}
<div className="aspect-[2/3] bg-gray-200 dark:bg-gray-700" />
<div className={`bg-gray-200 dark:bg-gray-700 ${
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
}`} />
{/* Content Skeleton */}
<div className="p-4 space-y-3">
+16 -9
View File
@@ -118,18 +118,21 @@ export function Header() {
<div className="container mx-auto px-4 py-3 md:py-4 max-w-7xl">
<div className="flex items-center justify-between">
{/* Logo and Version Badge */}
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
<Link href="/" className="flex items-center gap-2 min-w-0">
<img
src="/rmab_32x32.png"
src="/RMAB_1024x1024_ICON.png"
alt="ReadMeABook Logo"
className="w-8 h-8"
className="w-8 h-8 flex-shrink-0"
/>
<span className="text-lg md:text-xl font-bold text-gray-900 dark:text-gray-100">
<span className="text-lg md:text-xl font-bold text-gray-900 dark:text-gray-100 truncate">
ReadMeABook
</span>
</Link>
<VersionBadge />
{/* Hide version badge on mobile to prevent overlap */}
<div className="hidden sm:block flex-shrink-0">
<VersionBadge />
</div>
</div>
{/* Desktop Navigation */}
@@ -203,7 +206,7 @@ export function Header() {
</button>
{user ? (
<div className="relative" ref={containerRef}>
<div className="relative flex-shrink-0" ref={containerRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
@@ -212,10 +215,10 @@ export function Header() {
<img
src={user.avatarUrl}
alt={user.username}
className="w-8 h-8 rounded-full"
className="w-8 h-8 rounded-full flex-shrink-0"
/>
) : (
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-medium">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-medium flex-shrink-0">
{user.username.charAt(0).toUpperCase()}
</div>
)}
@@ -278,6 +281,10 @@ export function Header() {
</Link>
)}
</nav>
{/* Version badge in mobile menu */}
<div className="sm:hidden mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 px-3">
<VersionBadge />
</div>
</div>
)}
</div>
+60
View File
@@ -0,0 +1,60 @@
/**
* Component: Square Covers Toggle
* Documentation: UI toggle for switching between square (1:1) and rectangle (2:3) cover aspect ratios
*/
'use client';
import React from 'react';
interface SquareCoversToggleProps {
enabled: boolean;
onToggle: (enabled: boolean) => void;
}
export function SquareCoversToggle({ enabled, onToggle }: SquareCoversToggleProps) {
return (
<button
onClick={() => onToggle(!enabled)}
aria-label={enabled ? 'Switch to rectangular covers' : 'Switch to square covers'}
aria-pressed={enabled}
title={enabled ? 'Square covers (on)' : 'Square covers (off)'}
className={`
p-1.5 rounded-md transition-all duration-200
${enabled
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
: 'text-gray-600 dark:text-gray-400 hover:bg-white/20 dark:hover:bg-gray-700/50'
}
`}
>
{/* Crop/aspect ratio icon */}
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{/* Square frame representing crop to 1:1 */}
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Inner crop marks suggesting aspect ratio change */}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9h4M3 15h4M21 9h-4M21 15h-4"
opacity={enabled ? 1 : 0.4}
/>
</svg>
</button>
);
}
+27 -1
View File
@@ -9,23 +9,28 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
interface Preferences {
cardSize: number; // 1-9, default 5
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
}
interface PreferencesContextType {
cardSize: number;
setCardSize: (size: number) => void;
squareCovers: boolean;
setSquareCovers: (enabled: boolean) => void;
}
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
const DEFAULT_PREFERENCES: Preferences = {
cardSize: 5,
squareCovers: false,
};
const STORAGE_KEY = 'preferences';
export function PreferencesProvider({ children }: { children: ReactNode }) {
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
// Load preferences from localStorage on mount
useEffect(() => {
@@ -42,10 +47,13 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
// Invalid size, reset to default
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
}
// Load squareCovers preference (defaults to false if not set)
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
}
} catch (error) {
console.error('Failed to load preferences from localStorage:', error);
setCardSizeState(DEFAULT_PREFERENCES.cardSize);
setSquareCoversState(DEFAULT_PREFERENCES.squareCovers);
}
}, []);
@@ -68,6 +76,22 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
}
};
// Update square covers preference in state and localStorage
const setSquareCovers = (enabled: boolean) => {
if (typeof window === 'undefined') return;
setSquareCoversState(enabled);
try {
const stored = localStorage.getItem(STORAGE_KEY);
const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES };
preferences.squareCovers = enabled;
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch (error) {
console.error('Failed to save preferences to localStorage:', error);
}
};
// Listen for storage changes in other tabs (cross-tab sync)
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -80,6 +104,8 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
if (preferences.cardSize >= 1 && preferences.cardSize <= 9) {
setCardSizeState(preferences.cardSize);
}
// Sync squareCovers preference
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
} catch (error) {
console.error('Failed to parse preferences from storage event:', error);
}
@@ -93,7 +119,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
}, []);
return (
<PreferencesContext.Provider value={{ cardSize, setCardSize }}>
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers }}>
{children}
</PreferencesContext.Provider>
);