| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | useCallback, |
| 5 | useEffect, |
| 6 | useMemo, |
| 7 | useRef, |
| 8 | useState, |
| 9 | type KeyboardEvent, |
| 10 | } from "react"; |
| 11 | |
| 12 | import { Skeleton } from "@/components/ui/skeleton"; |
| 13 | import { isBuiltInPresentationTheme } from "@/lib/presentation/theme-resolution"; |
| 14 | import { type ThemeProperties } from "@/lib/presentation/themes"; |
| 15 | import { usePresentationState } from "@/states/presentation-state"; |
| 16 | import { ThemeCard } from "./ThemeCard"; |
| 17 | |
| 18 | const KEYBOARD_APPLY_DELAY_MS = 100; |
| 19 | const THEME_GRID_COLUMNS = 2; |
| 20 | |
| 21 | export interface ThemeListItem { |
| 22 | theme: ThemeProperties; |
| 23 | themeId: string; |
| 24 | isFavorite?: boolean; |
| 25 | likeCount?: number; |
| 26 | isLiked?: boolean; |
| 27 | canLike?: boolean; |
| 28 | showFavoriteButton?: boolean; |
| 29 | isUserTheme?: boolean; |
| 30 | isAdminTheme?: boolean; |
| 31 | canEditSystemTheme?: boolean; |
| 32 | } |
| 33 | |
| 34 | interface ThemeSelectorProps { |
| 35 | themes: ThemeListItem[]; |
| 36 | activeKey: string; |
| 37 | isLoading?: boolean; |
| 38 | emptyMessage?: string; |
| 39 | skeletonCount?: number; |
| 40 | } |
| 41 | |
| 42 | export function ThemeSelector({ |
| 43 | themes, |
| 44 | activeKey, |
| 45 | isLoading = false, |
| 46 | emptyMessage = "No themes available.", |
| 47 | skeletonCount = 6, |
| 48 | }: ThemeSelectorProps) { |
| 49 | const { userThemes, otherThemes, orderedThemes } = useMemo(() => { |
| 50 | const ownedThemes = themes.filter((item) => item.isUserTheme); |
| 51 | const sharedThemes = themes.filter((item) => !item.isUserTheme); |
| 52 | |
| 53 | return { |
| 54 | userThemes: ownedThemes, |
| 55 | otherThemes: sharedThemes, |
| 56 | orderedThemes: [...ownedThemes, ...sharedThemes], |
| 57 | }; |
| 58 | }, [themes]); |
| 59 | const hasUserThemes = userThemes.length > 0; |
| 60 | const hasOtherThemes = otherThemes.length > 0; |
| 61 | const activeThemeIndex = orderedThemes.findIndex( |
| 62 | (item) => item.themeId === activeKey, |
| 63 | ); |
| 64 | const initialSelectedIndex = activeThemeIndex >= 0 ? activeThemeIndex : null; |
| 65 | const [selectedIndex, setSelectedIndex] = useState<number | null>( |
| 66 | initialSelectedIndex, |
| 67 | ); |
| 68 | const cardRefs = useRef<Array<HTMLDivElement | null>>([]); |
| 69 | const applyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 70 | const activeKeyRef = useRef(activeKey); |
| 71 | const pendingKeyboardIndexRef = useRef<number | null>(null); |
| 72 | |
| 73 | useEffect(() => { |
| 74 | activeKeyRef.current = activeKey; |
| 75 | }, [activeKey]); |
| 76 | |
| 77 | const focusCard = useCallback((index: number) => { |
| 78 | cardRefs.current[index]?.focus(); |
| 79 | }, []); |
| 80 | |
| 81 | useEffect(() => { |
| 82 | setSelectedIndex(initialSelectedIndex); |
| 83 | |
| 84 | if (initialSelectedIndex === null) { |
| 85 | return; |
| 86 | } |
| 87 | |
| 88 | const animationFrameId = window.requestAnimationFrame(() => { |
| 89 | focusCard(initialSelectedIndex); |
| 90 | }); |
| 91 | |
| 92 | return () => window.cancelAnimationFrame(animationFrameId); |
| 93 | }, [focusCard, initialSelectedIndex]); |
| 94 | |
| 95 | useEffect( |
| 96 | () => () => { |
| 97 | if (applyTimeoutRef.current) { |
| 98 | clearTimeout(applyTimeoutRef.current); |
| 99 | } |
| 100 | }, |
| 101 | [], |
| 102 | ); |
| 103 | |
| 104 | const commitSelection = useCallback( |
| 105 | (index: number) => { |
| 106 | const item = orderedThemes[index]; |
| 107 | |
| 108 | if (!item) return; |
| 109 | if (item.themeId === activeKeyRef.current) return; |
| 110 | |
| 111 | activeKeyRef.current = item.themeId; |
| 112 | usePresentationState |
| 113 | .getState() |
| 114 | .setTheme( |
| 115 | item.themeId, |
| 116 | isBuiltInPresentationTheme(item.themeId) ? undefined : item.theme, |
| 117 | ); |
| 118 | }, |
| 119 | [orderedThemes], |
| 120 | ); |
| 121 | |
| 122 | const selectItem = useCallback( |
| 123 | (index: number) => { |
| 124 | if (applyTimeoutRef.current) { |
| 125 | clearTimeout(applyTimeoutRef.current); |
| 126 | applyTimeoutRef.current = null; |
| 127 | } |
| 128 | |
| 129 | pendingKeyboardIndexRef.current = null; |
| 130 | setSelectedIndex(index); |
| 131 | commitSelection(index); |
| 132 | }, |
| 133 | [commitSelection], |
| 134 | ); |
| 135 | |
| 136 | const schedulePendingKeyboardCommit = useCallback(() => { |
| 137 | if (pendingKeyboardIndexRef.current === null) return; |
| 138 | |
| 139 | if (applyTimeoutRef.current) { |
| 140 | clearTimeout(applyTimeoutRef.current); |
| 141 | } |
| 142 | |
| 143 | applyTimeoutRef.current = setTimeout(() => { |
| 144 | const pendingIndex = pendingKeyboardIndexRef.current; |
| 145 | applyTimeoutRef.current = null; |
| 146 | pendingKeyboardIndexRef.current = null; |
| 147 | |
| 148 | if (pendingIndex === null) return; |
| 149 | |
| 150 | commitSelection(pendingIndex); |
| 151 | }, KEYBOARD_APPLY_DELAY_MS); |
| 152 | }, [commitSelection]); |
| 153 | |
| 154 | const moveSelection = useCallback( |
| 155 | (nextIndex: number) => { |
| 156 | const boundedIndex = Math.min( |
| 157 | Math.max(nextIndex, 0), |
| 158 | orderedThemes.length - 1, |
| 159 | ); |
| 160 | |
| 161 | if (applyTimeoutRef.current) { |
| 162 | clearTimeout(applyTimeoutRef.current); |
| 163 | applyTimeoutRef.current = null; |
| 164 | } |
| 165 | |
| 166 | pendingKeyboardIndexRef.current = boundedIndex; |
| 167 | setSelectedIndex(boundedIndex); |
| 168 | focusCard(boundedIndex); |
| 169 | }, |
| 170 | [focusCard, orderedThemes.length], |
| 171 | ); |
| 172 | |
| 173 | const handleCardKeyDown = useCallback( |
| 174 | (event: KeyboardEvent<HTMLDivElement>, index: number) => { |
| 175 | switch (event.key) { |
| 176 | case "ArrowLeft": |
| 177 | event.preventDefault(); |
| 178 | event.stopPropagation(); |
| 179 | moveSelection(index - 1); |
| 180 | break; |
| 181 | case "ArrowRight": |
| 182 | event.preventDefault(); |
| 183 | event.stopPropagation(); |
| 184 | moveSelection(index + 1); |
| 185 | break; |
| 186 | case "ArrowUp": |
| 187 | event.preventDefault(); |
| 188 | event.stopPropagation(); |
| 189 | moveSelection(index - THEME_GRID_COLUMNS); |
| 190 | break; |
| 191 | case "ArrowDown": |
| 192 | event.preventDefault(); |
| 193 | event.stopPropagation(); |
| 194 | moveSelection(index + THEME_GRID_COLUMNS); |
| 195 | break; |
| 196 | case "Home": |
| 197 | event.preventDefault(); |
| 198 | event.stopPropagation(); |
| 199 | moveSelection(0); |
| 200 | break; |
| 201 | case "End": |
| 202 | event.preventDefault(); |
| 203 | event.stopPropagation(); |
| 204 | moveSelection(orderedThemes.length - 1); |
| 205 | break; |
| 206 | } |
| 207 | }, |
| 208 | [moveSelection, orderedThemes.length], |
| 209 | ); |
| 210 | |
| 211 | const handleCardKeyUp = useCallback( |
| 212 | (event: KeyboardEvent<HTMLDivElement>) => { |
| 213 | switch (event.key) { |
| 214 | case "ArrowLeft": |
| 215 | case "ArrowRight": |
| 216 | case "ArrowUp": |
| 217 | case "ArrowDown": |
| 218 | case "Home": |
| 219 | case "End": |
| 220 | event.preventDefault(); |
| 221 | event.stopPropagation(); |
| 222 | schedulePendingKeyboardCommit(); |
| 223 | break; |
| 224 | } |
| 225 | }, |
| 226 | [schedulePendingKeyboardCommit], |
| 227 | ); |
| 228 | |
| 229 | if (isLoading) { |
| 230 | return ( |
| 231 | <div className="grid grid-cols-2 gap-3 px-4 pt-3"> |
| 232 | {Array.from({ length: skeletonCount }).map((_, index) => ( |
| 233 | <ThemeCardSkeleton key={`theme-skeleton-${index}`} /> |
| 234 | ))} |
| 235 | </div> |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | if (!themes.length) { |
| 240 | return ( |
| 241 | <div className="mx-4 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground"> |
| 242 | <span>{emptyMessage}</span> |
| 243 | </div> |
| 244 | ); |
| 245 | } |
| 246 | |
| 247 | return ( |
| 248 | <div className="scrollbar-thin max-h-[calc(100vh-64px-2*80px)] overflow-y-auto scrollbar-thumb-primary scrollbar-track-transparent"> |
| 249 | {hasUserThemes && ( |
| 250 | <div className="px-4 pt-3"> |
| 251 | <h3 className="mb-3 text-xs font-semibold tracking-wider text-muted-foreground uppercase"> |
| 252 | My Themes |
| 253 | </h3> |
| 254 | <div className="grid grid-cols-2 gap-3"> |
| 255 | {userThemes.map((item, index) => ( |
| 256 | <ThemeCard |
| 257 | key={item.themeId} |
| 258 | themeId={item.themeId} |
| 259 | theme={item.theme} |
| 260 | isSelected={activeKey === item.themeId} |
| 261 | isFavorite={item.isFavorite} |
| 262 | likeCount={item.likeCount} |
| 263 | isLiked={item.isLiked} |
| 264 | showLikeButton={item.canLike ?? false} |
| 265 | showFavoriteButton={item.showFavoriteButton} |
| 266 | personalizeLabel="Personlize" |
| 267 | isOwner={item.isUserTheme} |
| 268 | isPublic={false} |
| 269 | isAdminTheme={item.isAdminTheme} |
| 270 | canEditSystemTheme={item.canEditSystemTheme} |
| 271 | isFocused={selectedIndex === index} |
| 272 | refCallback={(node) => { |
| 273 | cardRefs.current[index] = node; |
| 274 | }} |
| 275 | tabIndex={selectedIndex === index ? 0 : -1} |
| 276 | onSelect={() => selectItem(index)} |
| 277 | onFocus={() => setSelectedIndex(index)} |
| 278 | onKeyDown={(event) => handleCardKeyDown(event, index)} |
| 279 | onKeyUp={handleCardKeyUp} |
| 280 | /> |
| 281 | ))} |
| 282 | </div> |
| 283 | </div> |
| 284 | )} |
| 285 | |
| 286 | {hasOtherThemes && ( |
| 287 | <div className={`px-4 ${hasUserThemes ? "pt-6" : "pt-3"}`}> |
| 288 | {hasUserThemes && ( |
| 289 | <h3 className="mb-3 text-xs font-semibold tracking-wider text-muted-foreground uppercase"> |
| 290 | ALLWEONE Themes |
| 291 | </h3> |
| 292 | )} |
| 293 | <div className="grid grid-cols-2 gap-3"> |
| 294 | {otherThemes.map((item, publicIndex) => { |
| 295 | const index = userThemes.length + publicIndex; |
| 296 | |
| 297 | return ( |
| 298 | <ThemeCard |
| 299 | key={item.themeId} |
| 300 | themeId={item.themeId} |
| 301 | theme={item.theme} |
| 302 | isSelected={activeKey === item.themeId} |
| 303 | isFavorite={item.isFavorite} |
| 304 | likeCount={item.likeCount} |
| 305 | isLiked={item.isLiked} |
| 306 | showLikeButton={item.canLike ?? false} |
| 307 | showFavoriteButton={item.showFavoriteButton} |
| 308 | personalizeLabel="Personlize" |
| 309 | isOwner={item.isUserTheme} |
| 310 | isPublic={!item.isUserTheme} |
| 311 | isAdminTheme={item.isAdminTheme} |
| 312 | canEditSystemTheme={item.canEditSystemTheme} |
| 313 | isFocused={selectedIndex === index} |
| 314 | refCallback={(node) => { |
| 315 | cardRefs.current[index] = node; |
| 316 | }} |
| 317 | tabIndex={selectedIndex === index ? 0 : -1} |
| 318 | onSelect={() => selectItem(index)} |
| 319 | onFocus={() => setSelectedIndex(index)} |
| 320 | onKeyDown={(event) => handleCardKeyDown(event, index)} |
| 321 | onKeyUp={handleCardKeyUp} |
| 322 | /> |
| 323 | ); |
| 324 | })} |
| 325 | </div> |
| 326 | </div> |
| 327 | )} |
| 328 | </div> |
| 329 | ); |
| 330 | } |
| 331 | |
| 332 | function ThemeCardSkeleton() { |
| 333 | return ( |
| 334 | <div className="relative rounded-xl border border-border bg-card/50 p-2"> |
| 335 | <Skeleton className="aspect-4/3 w-full rounded-lg" /> |
| 336 | <div className="mt-3 space-y-2 px-2 pb-2"> |
| 337 | <Skeleton className="h-4 w-3/5" /> |
| 338 | <Skeleton className="h-3 w-2/5" /> |
| 339 | </div> |
| 340 | </div> |
| 341 | ); |
| 342 | } |
| 343 |