mirror of
https://github.com/chenasraf/stimvisor.git
synced 2026-05-17 17:38:11 +00:00
feat: selection preps
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"@fontsource-variable/nunito": "^5.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
|
||||
47
frontend/pnpm-lock.yaml
generated
47
frontend/pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -502,6 +505,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-checkbox@1.1.2':
|
||||
resolution: {integrity: sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.1':
|
||||
resolution: {integrity: sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==}
|
||||
peerDependencies:
|
||||
@@ -736,6 +752,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.0':
|
||||
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.0':
|
||||
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
|
||||
peerDependencies:
|
||||
@@ -2738,6 +2763,22 @@ snapshots:
|
||||
'@types/react': 18.3.11
|
||||
'@types/react-dom': 18.3.1
|
||||
|
||||
'@radix-ui/react-checkbox@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.11)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.1(@types/react@18.3.11)(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.11)(react@18.3.1)
|
||||
'@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.11)(react@18.3.1)
|
||||
'@radix-ui/react-use-size': 1.1.0(@types/react@18.3.11)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
'@types/react-dom': 18.3.1
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
@@ -2953,6 +2994,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.0(@types/react@18.3.11)(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.11
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.0(@types/react@18.3.11)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/rect': 1.1.0
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GamesHomePage } from './pages/Games/GamesHomePage'
|
||||
import ScreenshotsGamePage from './pages/Screenshots/ScreenshotsGamePage'
|
||||
import { ScreenshotsHome } from './pages/Screenshots/ScreenshotsHomePage'
|
||||
import { GameInfoPage } from './pages/Games/GameInfoPage'
|
||||
import { TooltipProvider } from '@ui/tooltip'
|
||||
|
||||
function App() {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
@@ -21,18 +22,20 @@ function App() {
|
||||
<HashRouter basename="/">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContextProvider>
|
||||
<div id="App" className="min-h-screen flex">
|
||||
<MainSidebar className="min-w-64 w-64" />
|
||||
<div className="max-h-screen overflow-y-auto w-full">
|
||||
<Routes>
|
||||
<Route path="/" element={<GamesHomePage />} />
|
||||
<Route path="/games/:gameId" element={<GameInfoPage />} />
|
||||
<Route path="/games/" element={<GamesHomePage />} />
|
||||
<Route path="/screenshots/:gameId" element={<ScreenshotsGamePage />} />
|
||||
<Route path="/screenshots/" element={<ScreenshotsHome />} />
|
||||
</Routes>
|
||||
<TooltipProvider>
|
||||
<div id="App" className="min-h-screen flex">
|
||||
<MainSidebar className="min-w-64 w-64" />
|
||||
<div className="max-h-screen overflow-y-auto w-full">
|
||||
<Routes>
|
||||
<Route path="/" element={<GamesHomePage />} />
|
||||
<Route path="/games/:gameId" element={<GameInfoPage />} />
|
||||
<Route path="/games/" element={<GamesHomePage />} />
|
||||
<Route path="/screenshots/:gameId" element={<ScreenshotsGamePage />} />
|
||||
<Route path="/screenshots/" element={<ScreenshotsHome />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
</HashRouter>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link } from 'react-router-dom'
|
||||
import { NativeOpen } from '$app'
|
||||
import { ScreenshotImg } from '@/components/ScreenshotImg/ScreenshotImg'
|
||||
import { useMemo } from 'react'
|
||||
import { OpenFolderIcon } from '../Icons/Icons'
|
||||
|
||||
export function GameScreenshotsListItem({
|
||||
className,
|
||||
@@ -43,7 +44,7 @@ export function GameScreenshotsListItem({
|
||||
<Link to={`/screenshots/${coll.gameId}`}>View All ({coll.totalCount})</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => NativeOpen(coll.dir)}>
|
||||
Browse Folder
|
||||
<OpenFolderIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
17
frontend/src/components/Icons/Icons.tsx
Normal file
17
frontend/src/components/Icons/Icons.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
FaAngleLeft,
|
||||
FaAngleRight,
|
||||
FaArrowUpRightFromSquare,
|
||||
FaRegFolderOpen,
|
||||
FaTrash,
|
||||
} from 'react-icons/fa6'
|
||||
|
||||
export const DeleteIcon = FaTrash
|
||||
|
||||
export const OpenFolderIcon = FaRegFolderOpen
|
||||
|
||||
export const ChevronLeftIcon = FaAngleLeft
|
||||
|
||||
export const ChevronRightIcon = FaAngleRight
|
||||
|
||||
export const ExternalLinkIcon = FaArrowUpRightFromSquare
|
||||
@@ -1,21 +1,39 @@
|
||||
import { HtmlProps } from '@/common/types'
|
||||
import { screenshots } from '$models'
|
||||
import { cn } from '@/common/utils'
|
||||
import { Checkbox } from '@ui/checkbox'
|
||||
|
||||
export function ScreenshotImg({
|
||||
className,
|
||||
screenshot,
|
||||
load = true,
|
||||
selectable = false,
|
||||
selected = false,
|
||||
onToggleSelect,
|
||||
...rest
|
||||
}: Omit<HtmlProps<'img'>, 'src'> & { screenshot: screenshots.ScreenshotEntry; load?: boolean }) {
|
||||
return load ? (
|
||||
<img
|
||||
className={cn('rounded-md', rest.onClick && 'cursor-pointer', className)}
|
||||
src={screenshot.url}
|
||||
alt={screenshot.name}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn('rounded-md', rest.onClick && 'cursor-pointer', className)} />
|
||||
)
|
||||
}: Omit<HtmlProps<'img'>, 'src'> & {
|
||||
screenshot: screenshots.ScreenshotEntry
|
||||
load?: boolean
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
onToggleSelect?(value: boolean): void
|
||||
}) {
|
||||
const img = <img src={screenshot.url} alt={screenshot.name} {...rest} />
|
||||
|
||||
if (selectable) {
|
||||
return (
|
||||
<div className={cn('rounded-md relative', rest.onClick && 'cursor-pointer', className)}>
|
||||
{img}
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => {
|
||||
console.log('toggle select', selected)
|
||||
onToggleSelect?.(!selected)
|
||||
}}
|
||||
className="absolute top-1 right-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className={cn('rounded-md', rest.onClick && 'cursor-pointer', className)}>{img}</div>
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ScreenshotImg } from '../ScreenshotImg/ScreenshotImg'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, ButtonProps } from '../ui/button'
|
||||
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa6'
|
||||
import { ManageScreenshot, NativeOpen } from '$app'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -17,10 +16,14 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../ui/alert-dialog'
|
||||
import { FaTrash } from 'react-icons/fa6'
|
||||
import { FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
||||
import { FaRegFolderOpen } from 'react-icons/fa6'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DeleteIcon,
|
||||
ExternalLinkIcon,
|
||||
OpenFolderIcon,
|
||||
} from '../Icons/Icons'
|
||||
|
||||
export function ScreenshotsCarouselModal(
|
||||
props: HtmlProps<'div'> & {
|
||||
@@ -80,17 +83,17 @@ export function ScreenshotsCarouselModal(
|
||||
const actions = [
|
||||
{
|
||||
label: 'Open Containing Folder',
|
||||
icon: <FaRegFolderOpen />,
|
||||
icon: <OpenFolderIcon />,
|
||||
onClick: () => NativeOpen(screenshots[idx].dir),
|
||||
},
|
||||
{
|
||||
label: 'Open in Default App',
|
||||
icon: <FaArrowUpRightFromSquare />,
|
||||
icon: <ExternalLinkIcon />,
|
||||
onClick: () => NativeOpen(screenshots[idx].path),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <FaTrash />,
|
||||
icon: <DeleteIcon />,
|
||||
onClick: () => setConfirmDelete(true),
|
||||
variant: 'destructive',
|
||||
},
|
||||
@@ -108,7 +111,7 @@ export function ScreenshotsCarouselModal(
|
||||
onClick={prevScreenshot}
|
||||
aria-keyshortcuts="ArrowLeft"
|
||||
>
|
||||
<FaAngleLeft />
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<div className="flex-grow flex place-content-center">
|
||||
{visible.map((scr, i) => (
|
||||
@@ -126,7 +129,7 @@ export function ScreenshotsCarouselModal(
|
||||
onClick={nextScreenshot}
|
||||
aria-keyshortcuts="ArrowRight"
|
||||
>
|
||||
<FaAngleRight />
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex place-content-center gap-2">
|
||||
@@ -163,7 +166,7 @@ export function ScreenshotsCarouselModal(
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
||||
<FaTrash />
|
||||
<DeleteIcon />
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
28
frontend/src/components/ui/checkbox.tsx
Normal file
28
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -2,7 +2,6 @@ import { Link, useParams } from 'react-router-dom'
|
||||
import { NativeOpen } from '$app'
|
||||
import { LoadingContainer } from '@/components/Loader/LoadingContainer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FaAngleLeft } from 'react-icons/fa6'
|
||||
import { ScreenshotImg } from '@/components/ScreenshotImg/ScreenshotImg'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ScreenshotsCarouselModal } from '@/components/ScreenshotsCarouselModal/ScreenshotsCarouselModal'
|
||||
@@ -11,11 +10,26 @@ import { useGameScreenshots } from '@/common/hooks/useScreenshots'
|
||||
import { FixedSizeGrid } from 'react-window'
|
||||
import { useStateRef } from '@/common/hooks/useStateRef'
|
||||
import { useScreenshotsModal } from '@/components/ScreenshotsCarouselModal/useScreenshotsModal'
|
||||
import { screenshots } from '$models'
|
||||
import { ChevronLeftIcon, OpenFolderIcon } from '@/components/Icons/Icons'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/tooltip'
|
||||
|
||||
function ScreenshotsGamePage() {
|
||||
const thumbSize = 256 + 8
|
||||
const { gameId } = useParams()
|
||||
const { screenshots, isPending, refetch } = useGameScreenshots(gameId!)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const setSelectedValue = useCallback(
|
||||
(file: screenshots.ScreenshotEntry) => () => {
|
||||
setSelected((selected) => {
|
||||
if (selected.includes(file.path)) {
|
||||
return selected.filter((s) => s !== file.path)
|
||||
}
|
||||
return [...selected, file.path]
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
const [dir] = screenshots.screenshotCollections ?? [{ screenshots: [] }]
|
||||
const {
|
||||
modalIndex,
|
||||
@@ -58,29 +72,36 @@ function ScreenshotsGamePage() {
|
||||
}
|
||||
}, [colCount, getColCount, gridRef, setGridRef, thumbSize])
|
||||
|
||||
function Cell({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
}: {
|
||||
columnIndex: number
|
||||
rowIndex: number
|
||||
style: React.CSSProperties
|
||||
}) {
|
||||
const i = rowIndex * colCount + columnIndex
|
||||
const file = dir.screenshots[i]
|
||||
if (!file) return null
|
||||
return (
|
||||
<div style={{ width: thumbSize, ...style }} className="p-1">
|
||||
<ScreenshotImg
|
||||
className="rounded-md"
|
||||
screenshot={file}
|
||||
key={file.path}
|
||||
onClick={() => openScreenshotsModal(i, dir.screenshots)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const Cell = useCallback(
|
||||
function Cell({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
}: {
|
||||
columnIndex: number
|
||||
rowIndex: number
|
||||
style: React.CSSProperties
|
||||
}) {
|
||||
const i = rowIndex * colCount + columnIndex
|
||||
const file = dir.screenshots[i]
|
||||
const isSelected = Boolean(file) && selected.includes(file.path)
|
||||
if (!file) return null
|
||||
return (
|
||||
<div style={{ width: thumbSize, ...style }} className="p-1">
|
||||
<ScreenshotImg
|
||||
className="rounded-md"
|
||||
selectable
|
||||
selected={isSelected}
|
||||
onToggleSelect={setSelectedValue(file)}
|
||||
screenshot={file}
|
||||
key={file.path}
|
||||
onClick={() => openScreenshotsModal(i, dir.screenshots)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[colCount, dir.screenshots, openScreenshotsModal, selected, setSelectedValue, thumbSize],
|
||||
)
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -102,7 +123,7 @@ function ScreenshotsGamePage() {
|
||||
<div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/screenshots">
|
||||
<FaAngleLeft />
|
||||
<ChevronLeftIcon />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -112,9 +133,14 @@ function ScreenshotsGamePage() {
|
||||
Screenshots for <span className="text-black dark:text-white">{dir.gameName}</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => NativeOpen(dir.dir)}>
|
||||
Browse Folder
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button variant="outline" onClick={() => NativeOpen(dir.dir)}>
|
||||
<OpenFolderIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open containing folder</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@ui/*": [
|
||||
"./src/components/ui/*"
|
||||
],
|
||||
"$app": [
|
||||
"./wailsjs/go/main/App"
|
||||
],
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@ui/*": [
|
||||
"./src/components/ui/*"
|
||||
],
|
||||
"$app": [
|
||||
"./wailsjs/go/main/App"
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@ui': path.resolve(__dirname, './src/components/ui'),
|
||||
$app: path.resolve(__dirname, './wailsjs/go/main/App'),
|
||||
$main: path.resolve(__dirname, './wailsjs/go/main'),
|
||||
$runtime: path.resolve(__dirname, './wailsjs/runtime'),
|
||||
|
||||
Reference in New Issue
Block a user