Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ docs/*
.claude
.agents
graphify-out
SOCIAL_ROADMAP.md
SOCIAL_ROADMAP.md
explore-prototype*.html
4 changes: 3 additions & 1 deletion background/cache-config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Cache-first: return cached response if available, only hit network when missing.
export const CACHE_FIRST_API_PATHS = ['/contents']

// Network-first: try the network, fall back to cache when offline/slow.
export const NETWORK_FIRST_API_PATHS = [
'/date/events',
'/news/rss',
'/contents',
'/extension/notifications',
'/extension/searchbox',
]
Expand Down
9 changes: 9 additions & 0 deletions background/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { CacheNames } from './cache-names'
import {
CACHE_FIRST_API_PATHS,
CDN_NO_CACHE_PREFIXES,
NETWORK_FIRST_API_PATHS,
NEVER_CACHE_API_PATHS,
Expand Down Expand Up @@ -55,6 +56,14 @@ export function setupCaching() {
}),
]

registerRoute(
({ url, request }) => matchesApiPaths(url, request, CACHE_FIRST_API_PATHS),
new CacheFirst({
cacheName: CacheNames.api,
plugins: apiCachePlugins(),
})
)

registerRoute(
({ url, request }) => matchesApiPaths(url, request, SWR_API_PATHS),
new StaleWhileRevalidate({
Expand Down
47 changes: 47 additions & 0 deletions src/components/ui/image-slider/__tests__/image-slider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'bun:test'
import {
clampSlideIndex,
getNextSlideIndex,
getPrevSlideIndex,
resolveActiveSlideImage,
} from '../slider-utils'

describe('slider-utils', () => {
it('increments index and wraps around to 0 on the last item', () => {
expect(getNextSlideIndex(0, 3)).toBe(1)
expect(getNextSlideIndex(1, 3)).toBe(2)
expect(getNextSlideIndex(2, 3)).toBe(0)
})

it('handles single item count without advancing', () => {
expect(getNextSlideIndex(0, 1)).toBe(0)
expect(getPrevSlideIndex(0, 1)).toBe(0)
})

it('decrements index and wraps around to last item when at 0', () => {
expect(getPrevSlideIndex(2, 3)).toBe(1)
expect(getPrevSlideIndex(1, 3)).toBe(0)
expect(getPrevSlideIndex(0, 3)).toBe(2)
})

it('clamps requested index into valid bounds', () => {
expect(clampSlideIndex(-2, 5)).toBe(0)
expect(clampSlideIndex(10, 5)).toBe(4)
expect(clampSlideIndex(3, 5)).toBe(3)
expect(clampSlideIndex(0, 0)).toBe(0)
})

it('resolves active slide image with bounds wrapping and fallback', () => {
const images = [
'https://example.com/1.png',
'https://example.com/2.png',
'https://example.com/3.png',
]
expect(resolveActiveSlideImage(images, 0)).toBe('https://example.com/1.png')
expect(resolveActiveSlideImage(images, 1)).toBe('https://example.com/2.png')
expect(resolveActiveSlideImage(images, 2)).toBe('https://example.com/3.png')
expect(resolveActiveSlideImage([], 0, 'https://example.com/fallback.png')).toBe(
'https://example.com/fallback.png'
)
})
})
256 changes: 256 additions & 0 deletions src/components/ui/image-slider/image-slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { useState } from 'react'
import { Icon } from '@/icons'
import { cn } from '@/common/utils/cn'
import { useImageSlider, type UseImageSliderOptions } from './use-image-slider'

export interface ImageSliderDotsProps {
count: number
currentIndex: number
onSelect: (index: number) => void
className?: string
size?: 'sm' | 'md'
variant?: 'light' | 'dark'
}

export function ImageSliderDots({
count,
currentIndex,
onSelect,
className,
size = 'sm',
variant = 'light',
}: ImageSliderDotsProps) {
if (count <= 1) return null

return (
<div
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-full backdrop-blur-md pointer-events-auto',
variant === 'light'
? 'bg-black/50 border border-white/10'
: 'bg-base-200/80 border border-content',
className
)}
>
{Array.from({ length: count }).map((_, idx) => (
<button
key={`slider-dot-${idx}`}
type="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onSelect(idx)
}}
className={cn(
'rounded-full transition-all duration-300 cursor-pointer',
size === 'sm' ? 'h-1.5' : 'h-2',
idx === currentIndex
? variant === 'light'
? 'w-4 bg-white'
: 'w-3.5 bg-primary'
: variant === 'light'
? 'w-1.5 bg-white/40 hover:bg-white/70'
: 'w-1 bg-base-content/30 hover:bg-base-content/60'
)}
aria-label={`اسلاید ${idx + 1}`}
/>
))}
</div>
)
}

export interface ImageSliderArrowsProps {
onPrev: (e: React.MouseEvent) => void
onNext: (e: React.MouseEvent) => void
className?: string
variant?: 'dark' | 'glass'
}

export function ImageSliderArrows({
onPrev,
onNext,
className,
variant = 'dark',
}: ImageSliderArrowsProps) {
return (
<div
className={cn(
'absolute inset-y-0 inset-x-2 flex items-center justify-between pointer-events-none z-20',
className
)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onPrev(e)
}}
className={cn(
'pointer-events-auto w-7 h-7 rounded-xl flex items-center justify-center transition-all active:scale-95 shadow-xs cursor-pointer',
variant === 'dark'
? 'bg-black/60 hover:bg-black/80 text-white backdrop-blur-md border border-white/20'
: 'bg-base-100/80 hover:bg-base-100 text-content backdrop-blur-xs border border-content'
)}
aria-label="عکس قبلی"
>
<Icon name="chevronRight" size={13} />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onNext(e)
}}
className={cn(
'pointer-events-auto w-7 h-7 rounded-xl flex items-center justify-center transition-all active:scale-95 shadow-xs cursor-pointer',
variant === 'dark'
? 'bg-black/60 hover:bg-black/80 text-white backdrop-blur-md border border-white/20'
: 'bg-base-100/80 hover:bg-base-100 text-content backdrop-blur-xs border border-content'
)}
aria-label="عکس بعدی"
>
<Icon name="chevronLeft" size={13} />
</button>
</div>
)
}

export interface ImageSliderProps extends UseImageSliderOptions {
alt?: string
mode?: 'background' | 'image'
showArrows?: boolean
arrowsVisibility?: 'always' | 'hover'
arrowVariant?: 'dark' | 'glass'
showDots?: boolean
dotsPosition?: 'bottom-center' | 'top-start' | 'none'
dotsVariant?: 'light' | 'dark'
className?: string
imageClassName?: string
overlay?: React.ReactNode
children?:
| React.ReactNode
| ((slider: ReturnType<typeof useImageSlider>) => React.ReactNode)
onIndexChange?: (index: number) => void
onMouseEnter?: (e: React.MouseEvent) => void
onMouseLeave?: (e: React.MouseEvent) => void
}

export function ImageSlider({
images = [],
fallbackSrc,
alt = '',
autoPlay = true,
interval = 4500,
mode = 'background',
showArrows = true,
arrowsVisibility = 'hover',
arrowVariant = 'dark',
showDots = true,
dotsPosition = 'bottom-center',
dotsVariant = 'light',
className,
imageClassName,
overlay,
children,
onIndexChange,
onMouseEnter,
onMouseLeave,
}: ImageSliderProps) {
const [isHovered, setIsHovered] = useState(false)

const slider = useImageSlider({
images,
fallbackSrc,
autoPlay,
interval,
isPaused: isHovered,
onChange: onIndexChange,
})

const { currentImage, hasMultiple, count, currentIndex, next, prev, goTo } = slider

const handleMouseEnter = (e: React.MouseEvent) => {
setIsHovered(true)
onMouseEnter?.(e)
}

const handleMouseLeave = (e: React.MouseEvent) => {
setIsHovered(false)
onMouseLeave?.(e)
}

return (
<div
className={cn(
'relative w-full h-full select-none overflow-hidden',
className
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{mode === 'background' ? (
<div
className={cn(
'absolute inset-0 bg-cover bg-center transition-all duration-700 ease-out group-hover:scale-105',
imageClassName
)}
style={{
backgroundImage: currentImage
? `url(${currentImage})`
: undefined,
}}
/>
) : (
currentImage && (
<img
src={currentImage}
alt={alt}
className={cn(
'w-full h-full object-cover transition-transform duration-500 group-hover:scale-105',
imageClassName
)}
/>
)
)}

{overlay}

{showArrows && hasMultiple && (
<ImageSliderArrows
onPrev={prev}
onNext={next}
variant={arrowVariant}
className={cn(
arrowsVisibility === 'hover' &&
'opacity-0 group-hover:opacity-100 transition-opacity'
)}
/>
)}

{showDots && hasMultiple && dotsPosition !== 'none' && (
<div
className={cn(
'absolute z-20',
dotsPosition === 'bottom-center' &&
'bottom-2 inset-x-0 flex items-center justify-center',
dotsPosition === 'top-start' && 'top-2 right-2'
)}
>
<ImageSliderDots
count={count}
currentIndex={currentIndex}
onSelect={goTo}
variant={dotsVariant}
/>
</div>
)}

{typeof children === 'function' ? children(slider) : children}
</div>
)
}

ImageSlider.Dots = ImageSliderDots
ImageSlider.Arrows = ImageSliderArrows
3 changes: 3 additions & 0 deletions src/components/ui/image-slider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './image-slider'
export * from './use-image-slider'
export * from './slider-utils'
27 changes: 27 additions & 0 deletions src/components/ui/image-slider/slider-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export function getNextSlideIndex(current: number, count: number): number {
if (count <= 1) return 0
return (current + 1) % count
}

export function getPrevSlideIndex(current: number, count: number): number {
if (count <= 1) return 0
return (current - 1 + count) % count
}

export function clampSlideIndex(index: number, count: number): number {
if (count <= 0) return 0
if (index < 0) return 0
if (index >= count) return count - 1
return index
}

export function resolveActiveSlideImage(
images: string[] = [],
index: number = 0,
fallbackSrc?: string
): string | undefined {
if (images.length === 0) return fallbackSrc
if (images.length === 1) return images[0] || fallbackSrc
const validIndex = (index + images.length) % images.length
return images[validIndex] || fallbackSrc
}
Loading
Loading