From af46a8735764b31d92b6438c1972c6f3a1fa137d Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 13:43:06 +0200 Subject: [PATCH 1/4] refactor: resolve code quality TODOs --- src/app/games/components/GameFilters.tsx | 10 +- ...hreeWayToggle.tsx => SegmentedControl.tsx} | 11 +- src/components/ui/SuccessRateBar.tsx | 5 +- src/components/ui/VoteButtons.tsx | 5 +- .../image-selectors/ImageSelectorSwitcher.tsx | 148 ++++++++++++++---- src/components/ui/index.ts | 2 +- src/schemas/apiAccess.ts | 3 +- src/schemas/audit.ts | 3 +- src/schemas/common.ts | 5 +- src/schemas/deviceBrand.ts | 3 +- src/schemas/game.ts | 3 +- src/schemas/gpu.ts | 2 +- src/schemas/permission.ts | 3 +- src/schemas/soc.ts | 3 +- src/schemas/system.ts | 3 +- src/schemas/user.ts | 3 +- .../services/user-profile.service.test.ts | 12 +- src/server/services/user-profile.service.ts | 17 +- src/utils/badge-colors.ts | 14 ++ src/utils/vote.ts | 31 ---- 20 files changed, 188 insertions(+), 98 deletions(-) rename src/components/ui/{ThreeWayToggle.tsx => SegmentedControl.tsx} (76%) diff --git a/src/app/games/components/GameFilters.tsx b/src/app/games/components/GameFilters.tsx index d726bb488..8a22710fe 100644 --- a/src/app/games/components/GameFilters.tsx +++ b/src/app/games/components/GameFilters.tsx @@ -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' @@ -32,9 +32,9 @@ function GameFilters(props: Props) { const isModerator = hasRolePermission(userQuery.data?.role, Role.MODERATOR) const listingFilterOptions: [ - ThreeWayToggleOption, - ThreeWayToggleOption, - ThreeWayToggleOption, + SegmentedControlOption, + SegmentedControlOption, + SegmentedControlOption, ] = [ { value: 'all', label: 'All', icon: }, { @@ -84,7 +84,7 @@ function GameFilters(props: Props) { {isModerator && props.onListingFilterChange ? ( - { +export interface SegmentedControlOption { value: T label: string icon?: ReactNode } interface Props { - options: [ThreeWayToggleOption, ThreeWayToggleOption, ThreeWayToggleOption] + options: readonly [SegmentedControlOption, ...SegmentedControlOption[]] 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(props: Props) { +export function SegmentedControl(props: Props) { const size = props.size ?? 'md' return (
- {/* Options */} {props.options.map((option) => ( + +
+ + + -
+
{selectedService === imageServiceMap.rawg ? 'Using RAWG.io for game images' - : 'Using TheGamesDB for game images'} + : selectedService === imageServiceMap.tgdb + ? 'Using TheGamesDB for game images' + : 'Using IGDB for comprehensive game media'}
-
-
- {selectedService === imageServiceMap.rawg - ? 'RAWG.io provides comprehensive game data with screenshots and backgrounds' - : 'TheGamesDB offers high-quality boxart and game media from the community'} +
+ {selectedService === imageServiceMap.rawg && + 'RAWG.io provides comprehensive game data with screenshots and backgrounds'} + {selectedService === imageServiceMap.tgdb && + 'TheGamesDB offers high-quality boxart and game media from the community'} + {selectedService === imageServiceMap.igdb && + 'IGDB provides rich media including covers, artworks, and screenshots with detailed metadata'} +
- {/* Animated Image Selector */}
- + {selectedService === imageServiceMap.rawg ? ( + ) : selectedService === imageServiceMap.igdb ? ( + + + ) : ( + // Admin table URL parameters export const AdminTableParamsSchema = z.object({ search: z.string().default(''), page: z.number().int().positive().default(1), sortField: z.string().nullable().default(null), - sortDirection: z.enum(['asc', 'desc']).nullable().default(null), // TODO: extract + sortDirection: SortDirection.nullable().default(null), }) export const JsonValueSchema: z.ZodType = z.lazy(() => diff --git a/src/schemas/deviceBrand.ts b/src/schemas/deviceBrand.ts index bba2fedf2..a4f933728 100644 --- a/src/schemas/deviceBrand.ts +++ b/src/schemas/deviceBrand.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' export const DeviceBrandSortField = z.enum(['name', 'devicesCount']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetDeviceBrandsSchema = z .object({ diff --git a/src/schemas/game.ts b/src/schemas/game.ts index 74e62059d..8adc16785 100644 --- a/src/schemas/game.ts +++ b/src/schemas/game.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { HumanVerificationTokenSchema } from '@/features/human-verification/shared/schema' +import { SortDirection } from '@/schemas/common' import { ApprovalStatus } from '@orm' export const GameSortField = z.enum([ @@ -10,7 +11,7 @@ export const GameSortField = z.enum([ 'status', ]) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GameListingFilter = z.enum(['all', 'withListings', 'noListings']) diff --git a/src/schemas/gpu.ts b/src/schemas/gpu.ts index fa2ad1d3f..68d4b45f1 100644 --- a/src/schemas/gpu.ts +++ b/src/schemas/gpu.ts @@ -43,7 +43,7 @@ export const DeleteGpuSchema = z.object({ id: z.string().uuid() }) // Type exports for repository use // Use z.input for types that include defaults (what you pass in) // Use z.output for types after defaults are applied (what you get out) -// TODO: figure out why we use z.infer +// The remaining schemas do not apply defaults or transforms, so z.infer matches their parsed shape. export type GetGpusInput = z.input export type GetGpuOptionsInput = z.input export type CreateGpuInput = z.infer diff --git a/src/schemas/permission.ts b/src/schemas/permission.ts index a5a3d80cc..fd0a1c7e1 100644 --- a/src/schemas/permission.ts +++ b/src/schemas/permission.ts @@ -1,10 +1,11 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' import { Role, PermissionActionType } from '@orm' // Sorting and filtering schemas export const PermissionSortField = z.enum(['label', 'key', 'category', 'createdAt', 'updatedAt']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const PermissionCategory = z.enum(['CONTENT', 'MODERATION', 'USER_MANAGEMENT', 'SYSTEM']) diff --git a/src/schemas/soc.ts b/src/schemas/soc.ts index 0baa2c543..400364b9c 100644 --- a/src/schemas/soc.ts +++ b/src/schemas/soc.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' export const SoCSortField = z.enum(['name', 'manufacturer', 'devicesCount']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetSoCsSchema = z .object({ diff --git a/src/schemas/system.ts b/src/schemas/system.ts index 83c47bb9b..d48605c1c 100644 --- a/src/schemas/system.ts +++ b/src/schemas/system.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' export const SystemSortField = z.enum(['name', 'key', 'gamesCount']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetSystemsSchema = z .object({ diff --git a/src/schemas/user.ts b/src/schemas/user.ts index 76656b0bb..870032f19 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { PAGINATION, CHAR_LIMITS } from '@/data/constants' +import { SortDirection } from '@/schemas/common' import { Role } from '@orm' export const UserSortField = z.enum([ @@ -14,7 +15,7 @@ export const UserSortField = z.enum([ 'followersCount', 'followingCount', ]) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetAllUsersSchema = z .object({ diff --git a/src/server/services/user-profile.service.test.ts b/src/server/services/user-profile.service.test.ts index f33e45527..27f90b09d 100644 --- a/src/server/services/user-profile.service.test.ts +++ b/src/server/services/user-profile.service.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Role, type PrismaClient } from '@orm/client' -import { checkProfileAccess, PRIVATE_PROFILE_SETTINGS } from './user-profile.service' +import { + checkProfileAccess, + PRIVATE_PROFILE_SETTINGS, + PROFILE_ACCESS_REASONS, +} from './user-profile.service' function createMockPrisma() { return { @@ -36,7 +40,7 @@ describe('user-profile.service', () => { const result = await checkProfileAccess(prisma, 'missing-id', {}) - expect(result).toEqual({ accessible: false, reason: 'not_found' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.NOT_FOUND }) }) it('should return banned when user has active ban and viewer is not mod', async () => { @@ -50,7 +54,7 @@ describe('user-profile.service', () => { currentUserRole: Role.USER, }) - expect(result).toEqual({ accessible: false, reason: 'banned' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.BANNED }) }) it('should return accessible with isBanned when user has active ban but viewer is MODERATOR', async () => { @@ -83,7 +87,7 @@ describe('user-profile.service', () => { currentUserRole: Role.USER, }) - expect(result).toEqual({ accessible: false, reason: 'private' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.PRIVATE }) }) it('should return accessible when profile is private but viewer is the owner', async () => { diff --git a/src/server/services/user-profile.service.ts b/src/server/services/user-profile.service.ts index d3267c8a5..1bda2581e 100644 --- a/src/server/services/user-profile.service.ts +++ b/src/server/services/user-profile.service.ts @@ -17,6 +17,15 @@ interface PrivacySettings { followingVisible: boolean } +export const PROFILE_ACCESS_REASONS = { + NOT_FOUND: 'not_found', + BANNED: 'banned', + PRIVATE: 'private', +} as const + +export type ProfileAccessReason = + (typeof PROFILE_ACCESS_REASONS)[keyof typeof PROFILE_ACCESS_REASONS] + interface AccessibleProfile { accessible: true isBanned: boolean @@ -29,7 +38,7 @@ interface AccessibleProfile { interface InaccessibleProfile { accessible: false - reason: 'not_found' | 'banned' | 'private' // TODO: use constants or enums + reason: ProfileAccessReason } export type ProfileAccessResult = AccessibleProfile | InaccessibleProfile @@ -77,7 +86,7 @@ export async function checkProfileAccess( }, }) - if (!user) return { accessible: false, reason: 'not_found' } + if (!user) return { accessible: false, reason: PROFILE_ACCESS_REASONS.NOT_FOUND } const isBanned = user.userBans.length > 0 const canViewBannedUsers = roleIncludesRole(ctx.currentUserRole, Role.MODERATOR) @@ -85,7 +94,7 @@ export async function checkProfileAccess( const isMod = canViewBannedUsers if (isBanned && !canViewBannedUsers) { - return { accessible: false, reason: 'banned' } + return { accessible: false, reason: PROFILE_ACCESS_REASONS.BANNED } } const privacySettings: PrivacySettings = { @@ -98,7 +107,7 @@ export async function checkProfileAccess( } if (!privacySettings.profilePublic && !isOwner && !isMod) { - return { accessible: false, reason: 'private' } + return { accessible: false, reason: PROFILE_ACCESS_REASONS.PRIVATE } } return { diff --git a/src/utils/badge-colors.ts b/src/utils/badge-colors.ts index a4f8883be..952b27cf3 100644 --- a/src/utils/badge-colors.ts +++ b/src/utils/badge-colors.ts @@ -72,3 +72,17 @@ export function getPermissionCategoryBadgeVariant( ): BadgeVariant { return permissionCategoryVariantMap[permissionCategory] || 'default' } + +export function getSuccessRateBarColor(rate: number): string { + if (rate >= 95) return 'bg-green-600' + if (rate >= 85) return 'bg-green-500' + if (rate >= 75) return 'bg-green-400' + if (rate >= 65) return 'bg-lime-500' + if (rate >= 55) return 'bg-yellow-400' + if (rate >= 45) return 'bg-yellow-500' + if (rate >= 35) return 'bg-orange-400' + if (rate >= 25) return 'bg-orange-500' + if (rate >= 15) return 'bg-red-400' + if (rate >= 5) return 'bg-red-500' + return 'bg-red-600' +} diff --git a/src/utils/vote.ts b/src/utils/vote.ts index d35b08317..d90463936 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -1,34 +1,3 @@ -/** - * Utility functions for vote-related calculations and styling - */ - -/** - * Get the color class for the success rate bar based on the rate - * TODO: probably move this to badgeColors.ts - * @param rate - Success rate percentage (0-100) - * @returns Tailwind CSS background color class - */ -export function getBarColor(rate: number): string { - if (rate >= 95) return 'bg-green-600' // Excellent - dark green - if (rate >= 85) return 'bg-green-500' // Very good - green - if (rate >= 75) return 'bg-green-400' // Good - light green - if (rate >= 65) return 'bg-lime-500' // Above average - lime - if (rate >= 55) return 'bg-yellow-400' // Average+ - light yellow - if (rate >= 45) return 'bg-yellow-500' // Average - yellow - if (rate >= 35) return 'bg-orange-400' // Below average - light orange - if (rate >= 25) return 'bg-orange-500' // Poor - orange - if (rate >= 15) return 'bg-red-400' // Bad - light red - if (rate >= 5) return 'bg-red-500' // Very bad - red - return 'bg-red-600' // Terrible - dark red -} - -/** - * Calculate the width percentage for the success rate bar - * When rate is 0 but there are votes, show full red bar (100%) - * @param rate - Success rate percentage (0-100) - * @param voteCount - Total number of votes - * @returns Width percentage for the bar - */ export function getBarWidth(rate: number, voteCount: number): number { return rate === 0 && voteCount > 0 ? 100 : rate } From 74dab00e914912c7887e30d2dc2bd9d20f6be6e8 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 15:57:37 +0200 Subject: [PATCH 2/4] Address code quality PR feedback --- src/components/ui/SuccessRateBar.tsx | 5 +- .../image-selectors/ImageSelectorSwitcher.tsx | 63 +++++++++---------- src/schemas/apiAccess.ts | 3 +- src/schemas/audit.ts | 6 +- src/schemas/common.ts | 6 +- src/schemas/cpu.ts | 5 +- src/schemas/device.ts | 5 +- src/schemas/deviceBrand.ts | 8 +-- src/schemas/emulator.ts | 5 +- src/schemas/game.ts | 9 +-- src/schemas/gpu.ts | 8 +-- src/schemas/listingReport.ts | 4 +- src/schemas/performanceScale.ts | 4 +- src/schemas/permission.ts | 8 +-- src/schemas/soc.ts | 6 +- src/schemas/system.ts | 6 +- src/schemas/user.ts | 6 +- src/schemas/userBan.ts | 4 +- src/schemas/voteInvestigation.ts | 5 +- 19 files changed, 70 insertions(+), 96 deletions(-) diff --git a/src/components/ui/SuccessRateBar.tsx b/src/components/ui/SuccessRateBar.tsx index 3ac01ee0e..6f429387d 100644 --- a/src/components/ui/SuccessRateBar.tsx +++ b/src/components/ui/SuccessRateBar.tsx @@ -1,6 +1,5 @@ 'use client' -import { useMemo } from 'react' import { cn } from '@/lib/utils' import { getSuccessRateBarColor } from '@/utils/badge-colors' import { getBarWidth } from '@/utils/vote' @@ -15,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(() => getSuccessRateBarColor(roundedRate), [roundedRate]) + const roundedRate = Math.round(props.rate) + const barColor = getSuccessRateBarColor(roundedRate) return (
diff --git a/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx b/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx index f09886c38..b143b3320 100644 --- a/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx +++ b/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx @@ -18,22 +18,19 @@ interface Props { className?: string } -type ImageService = 'rawg' | 'tgdb' | 'igdb' +const serviceOrder = ['rawg', 'tgdb', 'igdb'] as const +type ImageService = (typeof serviceOrder)[number] -const imageServiceMap: Record = { - rawg: 'rawg', - tgdb: 'tgdb', - igdb: 'igdb', -} +export function ImageSelectorSwitcher(props: Props) { + const [selectedService, setSelectedService] = useState('tgdb') + const [direction, setDirection] = useState(0) -const imageServiceDirection: Record = { - rawg: -1, - tgdb: 0, - igdb: 1, -} + const handleServiceChange = (service: ImageService) => { + if (service === selectedService) return -export function ImageSelectorSwitcher(props: Props) { - const [selectedService, setSelectedService] = useState(imageServiceMap.tgdb) + setDirection(serviceOrder.indexOf(service) - serviceOrder.indexOf(selectedService)) + setSelectedService(service) + } const slideVariants = { initial: (direction: number) => ({ @@ -59,10 +56,10 @@ export function ImageSelectorSwitcher(props: Props) {
diff --git a/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx b/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx index 349c08311..4e5c131fd 100644 --- a/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx +++ b/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx @@ -15,7 +15,7 @@ interface Props { selectedImageUrl?: string onImageSelect: (imageUrl: string) => void onError?: (error: string) => void - allowProviderSwitching?: boolean + allowIgdbProvider?: boolean className?: string } @@ -25,7 +25,7 @@ type ImageService = (typeof serviceOrder)[number] export function ImageSelectorSwitcher(props: Props) { const [selectedService, setSelectedService] = useState('tgdb') const [direction, setDirection] = useState(0) - const allowProviderSwitching = props.allowProviderSwitching === true + const allowIgdbProvider = props.allowIgdbProvider === true const handleServiceChange = (service: ImageService) => { if (service === selectedService) return @@ -34,20 +34,6 @@ export function ImageSelectorSwitcher(props: Props) { setSelectedService(service) } - if (!allowProviderSwitching) { - return ( -
- -
- ) - } - const slideVariants = { initial: (direction: number) => ({ x: direction > 0 ? 300 : -300, @@ -69,10 +55,16 @@ export function ImageSelectorSwitcher(props: Props) {
-
+
- + +
+ IGDB +
+ + NEW + + + )}