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
1 change: 1 addition & 0 deletions src/app/games/[id]/components/GameEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ export function GameEditForm(props: Props) {
selectedImageUrl={getCurrentImageUrl()}
onImageSelect={handleImageSelect}
onError={setError}
allowIgdbProvider={isModerator}
/>
</div>
</div>
Expand Down
10 changes: 5 additions & 5 deletions src/app/games/components/GameFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Joystick, Search, Filter, Eye, EyeOff, List } from 'lucide-react'
import { type ChangeEvent } from 'react'
import { Input, Autocomplete, ThreeWayToggle, type ThreeWayToggleOption } from '@/components/ui'
import { Input, Autocomplete, SegmentedControl, type SegmentedControlOption } from '@/components/ui'
import { api } from '@/lib/api'
import { hasRolePermission } from '@/utils/permissions'
import { Role } from '@orm'
Expand Down Expand Up @@ -32,9 +32,9 @@ function GameFilters(props: Props) {
const isModerator = hasRolePermission(userQuery.data?.role, Role.MODERATOR)

const listingFilterOptions: [
ThreeWayToggleOption<ListingFilterValue>,
ThreeWayToggleOption<ListingFilterValue>,
ThreeWayToggleOption<ListingFilterValue>,
SegmentedControlOption<ListingFilterValue>,
SegmentedControlOption<ListingFilterValue>,
SegmentedControlOption<ListingFilterValue>,
] = [
{ value: 'all', label: 'All', icon: <List className="w-4 h-4" /> },
{
Expand Down Expand Up @@ -84,7 +84,7 @@ function GameFilters(props: Props) {
</div>

{isModerator && props.onListingFilterChange ? (
<ThreeWayToggle
<SegmentedControl
options={listingFilterOptions}
value={currentFilter}
onChange={props.onListingFilterChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,32 @@ const paddingClasses = {
lg: 'px-4',
}

export interface ThreeWayToggleOption<T extends string> {
export interface SegmentedControlOption<T extends string> {
value: T
label: string
icon?: ReactNode
}

interface Props<T extends string> {
options: [ThreeWayToggleOption<T>, ThreeWayToggleOption<T>, ThreeWayToggleOption<T>]
options: readonly [SegmentedControlOption<T>, ...SegmentedControlOption<T>[]]
value: T
onChange: (value: T) => void
className?: string
size?: 'sm' | 'md' | 'lg'
}

// TODO: I feel like the english language has a better name for this
export function ThreeWayToggle<T extends string>(props: Props<T>) {
export function SegmentedControl<T extends string>(props: Props<T>) {
const size = props.size ?? 'md'

return (
<div
className={cn(
'relative inline-grid grid-cols-3 gap-1 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm p-1',
'relative inline-grid gap-1 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm p-1',
sizeClasses[size],
props.className,
)}
style={{ gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))` }}
>
{/* Options */}
{props.options.map((option) => (
<button
key={option.value}
Expand Down
8 changes: 4 additions & 4 deletions src/components/ui/SuccessRateBar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client'

import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { getBarColor, getBarWidth } from '@/utils/vote'
import { getSuccessRateBarColor } from '@/utils/badge-colors'
import { getBarWidth } from '@/utils/vote'

interface Props {
rate: number
Expand All @@ -14,8 +14,8 @@ interface Props {
export function SuccessRateBar(props: Props) {
const { compact = false } = props
const voteCount = props.voteCount ?? 0
const roundedRate = useMemo(() => Math.round(props.rate), [props.rate])
const barColor = useMemo(() => getBarColor(roundedRate), [roundedRate])
const roundedRate = Math.round(props.rate)
const barColor = getSuccessRateBarColor(roundedRate)

return (
<div className={cn('flex flex-col gap-1', compact ? 'w-20' : 'w-full')}>
Expand Down
5 changes: 3 additions & 2 deletions src/components/ui/VoteButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { CheckCircle, XCircle, HelpCircle } from 'lucide-react'
import { useState, type ComponentType } from 'react'
import analytics from '@/lib/analytics'
import toast from '@/lib/toast'
import { getBarColor, getBarWidth } from '@/utils/vote'
import { getSuccessRateBarColor } from '@/utils/badge-colors'
import { getBarWidth } from '@/utils/vote'
import { wilsonPercent } from '@/utils/wilson-score'

interface VoteButtonsProps {
Expand Down Expand Up @@ -125,7 +126,7 @@ export function VoteButtons(props: VoteButtonsProps) {
Math.max(0, optimisticTotalVotes - optimisticUpVotes),
)

const barColor = getBarColor(successRate)
const barColor = getSuccessRateBarColor(successRate)
const barWidth = getBarWidth(successRate, optimisticTotalVotes)

return (
Expand Down
173 changes: 132 additions & 41 deletions src/components/ui/image-selectors/ImageSelectorSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client'

import { motion, AnimatePresence } from 'framer-motion'
import { Database, Zap } from 'lucide-react'
import { Database, Sparkles, Zap } from 'lucide-react'
import { useState } from 'react'
import { Toggle } from '@/components/ui'
import { cn } from '@/lib/utils'
import { IGDBImageSelector } from './providers/IGDBImageSelector'
import { RawgImageSelector } from './providers/RawgImageSelector'
import { TGDBImageSelector } from './providers/TGDBImageSelector'

Expand All @@ -14,19 +15,24 @@ interface Props {
selectedImageUrl?: string
onImageSelect: (imageUrl: string) => void
onError?: (error: string) => void
allowIgdbProvider?: boolean
className?: string
}

type ImageService = 'rawg' | 'tgdb'

const imageServiceMap: Record<ImageService, ImageService> = {
rawg: 'rawg',
tgdb: 'tgdb',
// TODO: consider adding IGDB
}
const serviceOrder = ['rawg', 'tgdb', 'igdb'] as const
type ImageService = (typeof serviceOrder)[number]

export function ImageSelectorSwitcher(props: Props) {
const [selectedService, setSelectedService] = useState<ImageService>(imageServiceMap.tgdb)
const [selectedService, setSelectedService] = useState<ImageService>('tgdb')
const [direction, setDirection] = useState(0)
const allowIgdbProvider = props.allowIgdbProvider === true

const handleServiceChange = (service: ImageService) => {
if (service === selectedService) return

setDirection(serviceOrder.indexOf(service) - serviceOrder.indexOf(selectedService))
setSelectedService(service)
}

const slideVariants = {
initial: (direction: number) => ({
Expand All @@ -47,51 +53,120 @@ export function ImageSelectorSwitcher(props: Props) {

return (
<div className={props.className}>
{/* Service Selector */}
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex flex-col items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-orange-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">RAWG.io</span>
</div>
<Toggle
checked={selectedService === imageServiceMap.tgdb}
onChange={(checked) =>
setSelectedService(checked ? imageServiceMap.tgdb : imageServiceMap.rawg)
}
size="md"
/>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-500" />
<span className="font-medium text-gray-700 dark:text-gray-300">TheGamesDB</span>
<div className="space-y-4">
<div
className={cn(
'grid grid-cols-1 gap-2',
allowIgdbProvider ? 'sm:grid-cols-3' : 'sm:grid-cols-2',
)}
>
<button
type="button"
onClick={() => handleServiceChange('rawg')}
aria-pressed={selectedService === 'rawg'}
className={cn(
'p-3 rounded-lg border-2 transition-all',
selectedService === 'rawg'
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
)}
>
<Zap className="h-5 w-5 mx-auto mb-1 text-orange-500" />
<div
className={cn(
'text-sm font-medium mt-1',
selectedService === 'rawg'
? 'text-orange-600 dark:text-orange-400'
: 'text-gray-700 dark:text-gray-300',
)}
>
RAWG.io
</div>
</button>

<button
type="button"
onClick={() => handleServiceChange('tgdb')}
aria-pressed={selectedService === 'tgdb'}
className={cn(
'p-3 rounded-lg border-2 transition-all',
selectedService === 'tgdb'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
)}
>
<Database className="h-5 w-5 mx-auto mb-1 text-blue-500" />
<div
className={cn(
'text-sm font-medium mt-1',
selectedService === 'tgdb'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300',
)}
>
TheGamesDB
</div>
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">
Experimental
</span>
</div>
</button>

{allowIgdbProvider && (
<button
type="button"
onClick={() => handleServiceChange('igdb')}
aria-pressed={selectedService === 'igdb'}
className={cn(
'p-3 rounded-lg border-2 transition-all relative',
selectedService === 'igdb'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
)}
>
<Sparkles className="h-5 w-5 mx-auto mb-1 text-purple-500" />
<div
className={cn(
'text-sm font-medium mt-1',
selectedService === 'igdb'
? 'text-purple-600 dark:text-purple-400'
: 'text-gray-700 dark:text-gray-300',
)}
>
IGDB
</div>
<span className="absolute -top-1 -right-1 text-xs bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300 px-1 rounded text-[10px]">
NEW
</span>
</button>
)}
</div>

<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
{selectedService === imageServiceMap.rawg
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
{selectedService === 'rawg'
? 'Using RAWG.io for game images'
: 'Using TheGamesDB for game images'}
: selectedService === 'tgdb'
? 'Using TheGamesDB for game images'
: 'Using IGDB for comprehensive game media'}
</div>
</div>

<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
{selectedService === imageServiceMap.rawg
? 'RAWG.io provides comprehensive game data with screenshots and backgrounds'
: 'TheGamesDB offers high-quality boxart and game media from the community'}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center">
{selectedService === 'rawg' &&
'RAWG.io provides comprehensive game data with screenshots and backgrounds'}
{selectedService === 'tgdb' &&
'TheGamesDB offers high-quality boxart and game media from the community'}
{selectedService === 'igdb' &&
'IGDB provides rich media including covers, artworks, and screenshots with detailed metadata'}
</div>
</div>
</div>

{/* Animated Image Selector */}
<div className="relative overflow-hidden">
<AnimatePresence mode="wait" custom={selectedService === 'tgdb' ? 1 : -1}>
{selectedService === imageServiceMap.rawg ? (
<AnimatePresence mode="wait" custom={direction}>
{selectedService === 'rawg' ? (
<motion.div
key="rawg"
custom={-1}
custom={direction}
variants={slideVariants}
initial="initial"
animate="animate"
Expand All @@ -105,10 +180,26 @@ export function ImageSelectorSwitcher(props: Props) {
onError={props.onError}
/>
</motion.div>
) : selectedService === 'igdb' ? (
<motion.div
key="igdb"
custom={direction}
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
>
<IGDBImageSelector
gameTitle={props.gameTitle}
selectedImageUrl={props.selectedImageUrl}
onImageSelect={props.onImageSelect}
onError={props.onError}
/>
</motion.div>
) : (
<motion.div
key="tgdb"
custom={1}
custom={direction}
variants={slideVariants}
initial="initial"
animate="animate"
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ export * from './ProgressiveImage'
export * from './PullToRefresh'
export * from './RoleBadge'
export * from './SegmentedTabs'
export * from './SegmentedControl'
export * from './Skeleton'
export * from './SortableHeader'
export * from './SuccessRateBar'
export * from './SwipeableCard'
export * from './Switch'
export * from './ThemeSelect'
export * from './ThemeToggle'
export * from './ThreeWayToggle'
export * from './Tooltip'
export * from './UnderlineTabBar'
export * from './TrustLevelBadge'
Expand Down
2 changes: 1 addition & 1 deletion src/schemas/apiAccess.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { z } from 'zod'
import { SortDirectionSchema } from '@/schemas/common'
import { ApiUsagePeriod } from '@orm'

const quotaValueSchema = z.number().int().positive().max(1_000_000_000)
const quotaSchema = quotaValueSchema.or(z.literal(0)).or(z.null())

export const ApiKeySortFieldSchema = z.enum(['name', 'createdAt', 'lastUsedAt', 'monthlyQuota'])
export const SortDirectionSchema = z.enum(['asc', 'desc'])

export const CreateApiKeySchema = z.object({
name: z.string({ description: 'Friendly label for the key' }).trim().min(1).max(100).optional(),
Expand Down
5 changes: 2 additions & 3 deletions src/schemas/audit.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { z } from 'zod'
import { SortDirectionSchema } from '@/schemas/common'
import { AuditAction, AuditEntityType } from '@orm'

export const SortDirection = z.enum(['asc', 'desc'])

export const AuditLogSortField = z.enum([
'createdAt',
'action',
Expand All @@ -19,7 +18,7 @@ export const GetAuditLogsSchema = z
actorId: z.string().uuid().optional(),
targetUserId: z.string().uuid().optional(),
sortField: AuditLogSortField.optional(),
sortDirection: SortDirection.optional(),
sortDirection: SortDirectionSchema.optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(50),
dateFrom: z.string().datetime().optional(),
Expand Down
Loading
Loading