basic games page layout
This commit is contained in:
@@ -3,6 +3,7 @@ import { Sidebar } from './components/Sidebar';
|
||||
import { TopNav } from './components/TopNav';
|
||||
import LibraryGrid from './components/LibraryGrid';
|
||||
import { Login } from './components/Login';
|
||||
import { GamesPage } from './components/GamesPage';
|
||||
import { Settings } from './components/Settings';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { useGamepad } from './hooks/useGamepad';
|
||||
@@ -43,7 +44,7 @@ function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<LibraryGrid />} />
|
||||
<Route path="games" element={<LibraryGrid />} />
|
||||
<Route path="games" element={<GamesPage />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -2,8 +2,25 @@ export interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
system: string;
|
||||
size: string;
|
||||
coverUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Platform {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
romCount: number;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
export interface DetailedGame extends Game {
|
||||
summary?: string;
|
||||
developer?: string;
|
||||
publisher?: string;
|
||||
genres?: string[];
|
||||
releaseDate?: string;
|
||||
screenshots?: string[];
|
||||
}
|
||||
|
||||
export interface RommCollection {
|
||||
@@ -45,23 +62,23 @@ const getFullImageUrl = (urlPath: string | undefined): string | undefined => {
|
||||
|
||||
// Map RomM's native spec to our simplified app interface
|
||||
const mapRomToGame = (apiRom: any): Game => {
|
||||
let coverUrl = 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&q=80&w=400';
|
||||
|
||||
if (apiRom.url_cover) {
|
||||
coverUrl = getFullImageUrl(apiRom.url_cover) || coverUrl;
|
||||
} else if (apiRom.url_covers_large && apiRom.url_covers_large.length > 0) {
|
||||
coverUrl = getFullImageUrl(apiRom.url_covers_large[0]) || coverUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(apiRom.id),
|
||||
title: apiRom.name || apiRom.fs_name_no_ext || 'Unknown Title',
|
||||
title: apiRom.name || apiRom.fs_name,
|
||||
system: apiRom.platform_display_name || apiRom.platform_slug || 'Unknown Platform',
|
||||
coverUrl,
|
||||
size: apiRom.fs_size_bytes || 0
|
||||
size: formatBytes(apiRom.fs_size_bytes || 0),
|
||||
coverUrl: getFullImageUrl(apiRom.url_cover) || 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&q=80&w=400',
|
||||
};
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const rommApiClient = {
|
||||
get apiBase() {
|
||||
const cleanUrl = getBaseUrl();
|
||||
@@ -131,6 +148,42 @@ export const rommApiClient = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async fetchPlatforms(): Promise<Platform[]> {
|
||||
const res = await fetch(`${this.apiBase}/platforms`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch platforms.');
|
||||
const json = await res.json();
|
||||
return json.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.display_name || p.name || p.fs_slug,
|
||||
slug: p.fs_slug,
|
||||
romCount: p.rom_count || 0,
|
||||
iconUrl: getFullImageUrl(p.url_logo)
|
||||
}));
|
||||
},
|
||||
|
||||
async fetchGamesByPlatform(platformId: number): Promise<Game[]> {
|
||||
const res = await fetch(`${this.apiBase}/roms?platform_ids=${platformId}&limit=100`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch platform games.');
|
||||
const json = await res.json();
|
||||
return json.items ? json.items.map(mapRomToGame) : [];
|
||||
},
|
||||
|
||||
async fetchGameDetails(gameId: string): Promise<DetailedGame> {
|
||||
const res = await fetch(`${this.apiBase}/roms/${gameId}`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch game details.');
|
||||
const json = await res.json();
|
||||
const game = mapRomToGame(json);
|
||||
return {
|
||||
...game,
|
||||
summary: json.summary,
|
||||
developer: json.developer,
|
||||
publisher: json.publisher,
|
||||
genres: json.genres,
|
||||
releaseDate: json.release_date,
|
||||
screenshots: (json.screenshots || []).map((s: any) => getFullImageUrl(s.url) || '')
|
||||
};
|
||||
},
|
||||
|
||||
async fetchCollections(): Promise<RommCollection[]> {
|
||||
const res = await fetch(`${this.apiBase}/collections`, { headers: this.headers });
|
||||
if (!res.ok) throw new Error('Failed to fetch collections meta.');
|
||||
|
||||
360
src/components/GamesPage.tsx
Normal file
360
src/components/GamesPage.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { rommApiClient, Platform, Game } from '../api/client';
|
||||
|
||||
interface FocusableItemProps {
|
||||
onFocus: () => void;
|
||||
onEnterPress?: () => void;
|
||||
children: (focused: boolean) => React.ReactNode;
|
||||
className?: string;
|
||||
focusKey?: string;
|
||||
scrollOptions?: ScrollIntoViewOptions;
|
||||
}
|
||||
|
||||
const FocusableItem = ({ onFocus, onEnterPress, children, className, focusKey, scrollOptions }: FocusableItemProps) => {
|
||||
const { ref, focused } = useFocusable({
|
||||
onFocus: () => {
|
||||
onFocus();
|
||||
if (scrollOptions) {
|
||||
ref.current?.scrollIntoView(scrollOptions);
|
||||
} else {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}
|
||||
},
|
||||
onEnterPress,
|
||||
focusKey
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`${className} ${focused ? 'focused' : ''}`}>
|
||||
{children(focused)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformItem = ({ platform, active, onSelect }: { platform: Platform, active: boolean, onSelect: () => void }) => (
|
||||
<FocusableItem
|
||||
onFocus={onSelect}
|
||||
className="shrink-0"
|
||||
>
|
||||
{(focused) => (
|
||||
<div className="flex flex-col items-center gap-2 group cursor-pointer w-[110px]">
|
||||
<div className={`w-[90px] h-[90px] rounded-[24px] transition-all duration-500 border flex items-center justify-center p-5
|
||||
${focused ? 'bg-[#2563eb] border-[#2563eb] scale-110 shadow-[0_15px_40px_rgba(37,99,235,0.4)]' :
|
||||
active ? 'bg-white/10 border-white/20' : 'bg-white/5 border-white/5 group-hover:border-white/20'}`}
|
||||
>
|
||||
{platform.iconUrl ? (
|
||||
<img
|
||||
src={platform.iconUrl}
|
||||
alt={platform.name}
|
||||
className={`w-full h-full object-contain transition-all duration-500 ${focused ? 'scale-110 brightness-100' : 'grayscale brightness-200 opacity-30 group-hover:opacity-60'}`}
|
||||
/>
|
||||
) : (
|
||||
<span className={`material-symbols-outlined text-3xl transition-colors ${focused ? 'text-white' : 'text-white/20'}`}>sports_esports</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-[9px] font-black uppercase geist-mono tracking-widest text-center transition-all duration-300 w-full px-2 truncate
|
||||
${focused ? 'text-[#2563eb] translate-y-1' : active ? 'text-white' : 'text-white/20'}`}
|
||||
>
|
||||
{platform.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FocusableItem>
|
||||
);
|
||||
|
||||
const GameListItem = ({ game, active, onFocus }: { game: Game, active: boolean, onFocus: () => void }) => (
|
||||
<FocusableItem
|
||||
onFocus={onFocus}
|
||||
className="w-full"
|
||||
focusKey={`GAME_${game.id}`}
|
||||
scrollOptions={{ behavior: 'smooth', block: 'nearest' }}
|
||||
>
|
||||
{(focused) => (
|
||||
<div className={`px-4 py-2.5 transition-all duration-300 flex items-center gap-4 cursor-pointer group border-r-2
|
||||
${focused ? 'bg-white/10 border-[#2563eb]' :
|
||||
active ? 'bg-white/5 border-transparent' : 'bg-transparent border-transparent hover:bg-white/5'}`}
|
||||
>
|
||||
<div className="w-8 h-11 bg-white/5 rounded-[4px] overflow-hidden shrink-0 border border-white/5 group-hover:border-white/20 transition-colors">
|
||||
<img src={game.coverUrl} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-xs font-bold truncate transition-colors ${focused || active ? 'text-white' : 'text-white/60 group-hover:text-white/80'}`}>
|
||||
{game.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-white/40 font-medium geist-mono uppercase tracking-tight">
|
||||
{game.size}
|
||||
</div>
|
||||
</div>
|
||||
{focused && <div className="material-symbols-outlined text-[#2563eb] text-lg animate-pulse">chevron_right</div>}
|
||||
</div>
|
||||
)}
|
||||
</FocusableItem>
|
||||
);
|
||||
|
||||
const AlphabetScroller = ({
|
||||
letters,
|
||||
onLetterPress
|
||||
}: {
|
||||
letters: string[],
|
||||
onLetterPress: (letter: string) => void
|
||||
}) => {
|
||||
const { focusKey } = useFocusable({
|
||||
focusKey: 'ALPHABET_SCROLLER'
|
||||
});
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div className="w-10 flex flex-col items-center justify-center py-8 bg-black/40 border-l border-white/5 gap-2 select-none">
|
||||
{letters.map(letter => (
|
||||
<FocusableItem
|
||||
key={letter}
|
||||
onFocus={() => {}}
|
||||
onEnterPress={() => onLetterPress(letter)}
|
||||
className="w-full flex justify-center py-1"
|
||||
scrollOptions={{ behavior: 'smooth', block: 'center' }}
|
||||
>
|
||||
{(focused) => (
|
||||
<div className={`text-[13px] font-black geist-mono transition-all duration-200
|
||||
${focused ? 'text-[#2563eb] scale-125' : 'text-white/30 hover:text-white/70'}`}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
</FocusableItem>
|
||||
))}
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const GamesPage = () => {
|
||||
const { ref: pageRef, focusKey: pageFocusKey } = useFocusable();
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
|
||||
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
|
||||
|
||||
// Queries
|
||||
const platformsQuery = useQuery({
|
||||
queryKey: ['platforms'],
|
||||
queryFn: () => rommApiClient.fetchPlatforms()
|
||||
});
|
||||
|
||||
const gamesQuery = useQuery({
|
||||
queryKey: ['games', selectedPlatformId],
|
||||
queryFn: () => selectedPlatformId ? rommApiClient.fetchGamesByPlatform(selectedPlatformId) : Promise.resolve([]),
|
||||
enabled: !!selectedPlatformId
|
||||
});
|
||||
|
||||
const detailsQuery = useQuery({
|
||||
queryKey: ['gameDetails', selectedGameId],
|
||||
queryFn: () => selectedGameId ? rommApiClient.fetchGameDetails(selectedGameId) : Promise.resolve(null),
|
||||
enabled: !!selectedGameId
|
||||
});
|
||||
|
||||
const filteredPlatforms = React.useMemo(() =>
|
||||
platformsQuery.data?.filter(p => p.romCount > 0) || [],
|
||||
[platformsQuery.data]
|
||||
);
|
||||
|
||||
// Auto-select first items
|
||||
useEffect(() => {
|
||||
if (filteredPlatforms.length > 0 && selectedPlatformId === null) {
|
||||
setSelectedPlatformId(filteredPlatforms[0].id);
|
||||
}
|
||||
}, [filteredPlatforms, selectedPlatformId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gamesQuery.data && gamesQuery.data.length > 0 && !selectedGameId) {
|
||||
setSelectedGameId(gamesQuery.data[0].id);
|
||||
}
|
||||
}, [gamesQuery.data, selectedGameId]);
|
||||
|
||||
const handlePlatformFocus = (id: number) => {
|
||||
setSelectedPlatformId(id);
|
||||
setSelectedGameId(null); // Reset game selection when platform changes
|
||||
};
|
||||
|
||||
// Alphabet Logic
|
||||
const activeLetters = React.useMemo(() => {
|
||||
if (!gamesQuery.data) return [];
|
||||
const letters = new Set<string>();
|
||||
gamesQuery.data.forEach(game => {
|
||||
const firstChar = game.title.charAt(0).toUpperCase();
|
||||
if (/[A-Z]/.test(firstChar)) {
|
||||
letters.add(firstChar);
|
||||
} else {
|
||||
letters.add('#');
|
||||
}
|
||||
});
|
||||
return Array.from(letters).sort();
|
||||
}, [gamesQuery.data]);
|
||||
|
||||
const handleLetterPress = (letter: string) => {
|
||||
if (!gamesQuery.data) return;
|
||||
const game = gamesQuery.data.find(g => {
|
||||
const firstChar = g.title.charAt(0).toUpperCase();
|
||||
return letter === '#' ? !/[A-Z]/.test(firstChar) : firstChar === letter;
|
||||
});
|
||||
if (game) {
|
||||
setSelectedGameId(game.id);
|
||||
setFocus(`GAME_${game.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (platformsQuery.isLoading) return <div className="h-full flex items-center justify-center text-white/50 geist-mono uppercase">Initializing Archives...</div>;
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={pageFocusKey}>
|
||||
<div ref={pageRef} className="h-full flex flex-col bg-[#10131f] overflow-hidden">
|
||||
{/* Header / Platforms */}
|
||||
<div className="px-12 py-5 shrink-0 border-b border-white/5 bg-black/10">
|
||||
<div className="flex gap-6 overflow-x-auto no-scrollbar pb-2">
|
||||
{filteredPlatforms.map(p => (
|
||||
<PlatformItem
|
||||
key={p.id}
|
||||
platform={p}
|
||||
active={selectedPlatformId === p.id}
|
||||
onSelect={() => handlePlatformFocus(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Split Layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Column: Game List + Alphabet */}
|
||||
<div className="w-[500px] border-r border-white/5 flex overflow-hidden bg-black/20">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{gamesQuery.isLoading ? (
|
||||
<div className="p-12 text-center text-white/20 geist-mono text-[10px] uppercase">Retrieving index...</div>
|
||||
) : (
|
||||
gamesQuery.data?.map(game => (
|
||||
<GameListItem
|
||||
key={game.id}
|
||||
game={game}
|
||||
active={selectedGameId === game.id}
|
||||
onFocus={() => setSelectedGameId(game.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeLetters.length > 0 && (
|
||||
<AlphabetScroller
|
||||
letters={activeLetters}
|
||||
onLetterPress={handleLetterPress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Game Details */}
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar relative">
|
||||
{detailsQuery.data ? (
|
||||
<div className="p-16 animate-in fade-in slide-in-from-right-8 duration-700">
|
||||
<div className="flex gap-12 items-start">
|
||||
{/* Poster */}
|
||||
<div className="w-[320px] rounded-[16px] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 shrink-0 sticky top-0 group">
|
||||
<img
|
||||
src={detailsQuery.data.coverUrl}
|
||||
className="w-full aspect-[2/3] object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||
alt=""
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-60"></div>
|
||||
</div>
|
||||
|
||||
{/* Info Pane */}
|
||||
<div className="flex-1 pt-4">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span className="px-3 py-1 bg-[#2563eb] text-white text-[9px] font-black uppercase tracking-widest rounded-sm">PLAYABLE</span>
|
||||
<span className="text-white/40 text-[10px] geist-mono uppercase tracking-widest">ID: ROM-{detailsQuery.data.id}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-black text-white mb-4 tracking-tighter leading-none">{detailsQuery.data.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 mb-10 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-[#2563eb]">developer_board</span>
|
||||
<span className="text-[11px] font-bold text-white/80 uppercase geist-mono">{detailsQuery.data.developer || 'Unknown Dev'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-[#2563eb]">calendar_today</span>
|
||||
<span className="text-[11px] font-bold text-white/80 uppercase geist-mono">{detailsQuery.data.releaseDate || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-[#2563eb]">sports_esports</span>
|
||||
<span className="text-[11px] font-bold text-white/80 uppercase geist-mono">{detailsQuery.data.system}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-12">
|
||||
<FocusableItem
|
||||
onFocus={() => {}}
|
||||
onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{(focused) => (
|
||||
<button className={`flex items-center gap-3 px-8 py-4 rounded-full font-black uppercase tracking-tighter transition-all duration-300 shadow-xl ${focused ? 'bg-[#2563eb] text-white scale-105 shadow-[#2563eb]/40' : 'bg-white text-black hover:bg-white/90'}`}>
|
||||
<span className="material-symbols-outlined filled">play_arrow</span>
|
||||
Start Game
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
|
||||
<FocusableItem
|
||||
onFocus={() => {}}
|
||||
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{(focused) => (
|
||||
<button className={`flex items-center gap-3 px-8 py-4 rounded-full font-black uppercase tracking-tighter border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white hover:border-white/20 bg-white/5'}`}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</FocusableItem>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<section>
|
||||
<h3 className="text-[10px] font-black text-[#2563eb] uppercase tracking-[0.4em] mb-4">Brief Summary</h3>
|
||||
<p className="text-white/60 text-sm leading-relaxed font-medium">
|
||||
{detailsQuery.data.summary || "This title does not have a comprehensive analysis in our local database yet. Our researchers are working to archive more metadata for this specific entry."}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{detailsQuery.data.genres && detailsQuery.data.genres.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-[10px] font-black text-[#2563eb] uppercase tracking-[0.4em] mb-4">Classifications</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detailsQuery.data.genres.map(g => (
|
||||
<span key={g} className="px-3 py-1 bg-white/5 border border-white/5 rounded-full text-[9px] font-bold text-white/60 uppercase tracking-widest">{g}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedGameId ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="material-symbols-outlined text-4xl text-white/10 animate-spin mb-4">progress_activity</div>
|
||||
<div className="text-[10px] geist-mono text-white/20 uppercase tracking-widest">Hydrating Metadata...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center p-20 text-center opacity-20">
|
||||
<div>
|
||||
<span className="material-symbols-outlined text-6xl mb-6">videogame_asset</span>
|
||||
<p className="geist-mono uppercase tracking-[0.3em] font-black text-sm">Target selection required</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,31 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Premium Global Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* Firefox Support */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
Reference in New Issue
Block a user