Fixed: fixed bug scrcpy and shell is disconnect when switch page

This commit is contained in:
Kenta420 2024-02-19 14:24:05 +07:00
parent 9543d4541c
commit 0fe469b5c6
43 changed files with 1378 additions and 1366 deletions

View file

@ -1,11 +1,10 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ReloadIcon } from '@radix-ui/react-icons'
import { LinuxFileType, type Adb } from '@yume-chan/adb'
import { WritableStream } from '@yume-chan/stream-extra'
import { useCallback, useState } from 'react'
import { Consumable, WritableStream, ReadableStream } from '@yume-chan/stream-extra'
import JSZip from 'jszip'
import { useCallback, useState } from 'react'
interface FileManagerTabProps {
adb: Adb | undefined
@ -20,9 +19,32 @@ type filesType = {
export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
const [path, setPath] = useState<string>('')
const [pushPath, setPushPath] = useState<string>('')
const [pushFile, setPushFile] = useState<File | null>()
const [files, setFiles] = useState<filesType>()
const pushFiles = async (filename: string, blob: Blob, targetPath: string) => {
if (!adb) return
const buffer = await blob.arrayBuffer()
console.log(blob, buffer, targetPath, filename)
const sync = await adb.sync()
try {
await sync.write({
filename: targetPath + '/' + filename,
file: new ReadableStream({
start(controller) {
controller.enqueue(new Consumable(new Uint8Array(buffer)))
controller.close()
}
})
})
} finally {
await sync.dispose()
}
}
const zipFiles = (files: filesType) => {
const zip = new JSZip()
@ -132,10 +154,6 @@ export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
}
}, [adb, path])
const refresh = useCallback(() => {
console.log('Refreshing...')
}, [])
return (
<Card>
<CardHeader>
@ -143,19 +161,29 @@ export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
<CardDescription>Manage files in Android</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-end py-3 w-full">
<div className="flex w-full items-center justify-end">
<Button variant={'outline'} onClick={refresh}>
<ReloadIcon />
</Button>
<div className="flex w-full flex-col items-center justify-end py-3">
<div className="flex w-full items-center justify-around space-x-10">
<div className="flex space-x-5">
<Input placeholder="folder to download" value={path} onChange={e => setPath(e.target.value)} />
<Button variant={'default'} onClick={download}>
Download
</Button>
</div>
<div className="flex space-x-5">
<Input placeholder="path to push file" value={pushPath} onChange={e => setPushPath(e.target.value)} />
<Input type="file" accept="*" dir="ltr" onChange={e => setPushFile(e.target.files?.item(0))} />
<Button
variant={'default'}
onClick={
() => pushFiles(pushFile?.name || 'unname', pushFile as Blob, pushPath)
//testPushFile
}
>
Push
</Button>
</div>
</div>
<div className="flex space-x-5 w-96">
<Input placeholder="folder to download" value={path} onChange={e => setPath(e.target.value)} />
<Button variant={'default'} onClick={download}>
Download
</Button>
</div>
<div className="w-full max-h-96 overflow-y-auto">{files && <FileTree files={files} />}</div>
<div className="max-h-96 w-full overflow-y-auto">{files && <FileTree files={files} />}</div>
</div>
</CardContent>
</Card>

View file

@ -1,269 +1,38 @@
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { toast } from '@/components/ui/use-toast'
import { type Adb, type AdbSubprocessProtocol } from '@yume-chan/adb'
import { AdbScrcpyClient, AdbScrcpyOptions1_22 } from '@yume-chan/adb-scrcpy'
import {
AndroidKeyCode,
AndroidKeyEventAction,
AndroidMotionEventAction,
ScrcpyLogLevel1_18,
ScrcpyOptions1_25,
ScrcpyPointerId,
ScrcpyVideoCodecId
} from '@yume-chan/scrcpy'
import { WebCodecsDecoder } from '@yume-chan/scrcpy-decoder-webcodecs'
import { Consumable, WritableStream, ReadableStream, DecodeUtf8Stream } from '@yume-chan/stream-extra'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import useScrcpy from '@/hooks/scrcpy-android'
import { type Adb } from '@yume-chan/adb'
import { memo, useEffect, useRef } from 'react'
import 'xterm/css/xterm.css'
import { useShallow } from 'zustand/react/shallow'
interface ScrcpyTabProps {
adb: Adb | undefined
}
export const ScrcpyTab: React.FC<ScrcpyTabProps> = memo(({ adb }) => {
const logcatRef = useRef<HTMLDivElement>(null)
const scrcpyScreenRef = useRef<HTMLDivElement>(null)
const [process, setProcess] = useState<AdbSubprocessProtocol | undefined>()
const [client, setClient] = useState<AdbScrcpyClient | undefined>()
const [decoder, setDecoder] = useState<WebCodecsDecoder | undefined>()
console.log('rendering scrcpy tab')
const { scrcpyClient, decoder, connectScrcpy, onHomeClick, onBackClick, disconnectScrcpy } = useScrcpy(
useShallow(state => ({
scrcpyClient: state.scrcpyClient,
decoder: state.decoder,
connectScrcpy: state.connectScrcpy,
onHomeClick: state.onHomeClick,
onBackClick: state.onBackClick,
disconnectScrcpy: state.disconnectScrcpy
}))
)
useEffect(() => {
const startTerminal = async () => {
if (logcatRef.current && adb) {
const terminal: Terminal = new Terminal()
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
const process: AdbSubprocessProtocol = await adb.subprocess.shell('logcat')
process.stdout.pipeTo(
new WritableStream<Uint8Array>({
write(chunk) {
terminal.write(chunk)
}
})
)
terminal.options.disableStdin = true
terminal.options.theme = {
background: '#1e1e1e',
foreground: '#d4d4d4'
}
terminal.open(logcatRef.current)
fitAddon.fit()
setProcess(process)
}
if (decoder) {
scrcpyScreenRef.current?.appendChild(decoder.renderer)
decoder.renderer.style.width = '100%'
decoder.renderer.style.height = '100%'
} else {
if (scrcpyScreenRef.current) scrcpyScreenRef.current.innerHTML = ''
}
startTerminal()
return () => {
console.log('cleaning up logcat')
for (const child of logcatRef.current?.children || []) {
logcatRef.current?.removeChild(child)
}
process?.stderr.cancel()
process?.stdout.cancel()
process?.kill()
}
}, [adb])
const connectScrcpy = useCallback(async () => {
if (!adb) {
toast({
title: 'No ADB connection',
description: 'Please connect to a device first',
duration: 3000,
variant: 'destructive'
})
return
}
// clean up the scrcpy screen
if (scrcpyScreenRef.current && scrcpyScreenRef.current.children.length > 0) {
while (scrcpyScreenRef.current.firstChild) {
scrcpyScreenRef.current.removeChild(scrcpyScreenRef.current.firstChild)
}
}
// fetch the scrcpy server binary
// TODO: should load from real server instead of local file. Fix this later
const server: ArrayBuffer = await fetch(new URL('../../../scrcpy/scrcpy_server_v1.25', import.meta.url)).then(res =>
res.arrayBuffer()
)
// push the server binary to the device
const sync = await adb.sync()
try {
await sync.write({
filename: '/data/local/tmp/scrcpy-server.jar',
file: new ReadableStream({
start(controller) {
controller.enqueue(new Consumable(new Uint8Array(server)))
controller.close()
}
})
})
} finally {
await sync.dispose()
}
// start the scrcpy server
const res = await adb.subprocess.spawn(
'CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.25'
)
// pipe the server output to the console
res.stdout.pipeThrough(new DecodeUtf8Stream()).pipeTo(
new WritableStream({
write(chunk) {
console.log(chunk)
}
})
)
const scrcpyOption = new ScrcpyOptions1_25({
maxFps: 60,
bitRate: 4000000,
stayAwake: true,
control: true,
logLevel: ScrcpyLogLevel1_18.Debug
})
// start the scrcpy client
const _client = await AdbScrcpyClient.start(
adb,
'/data/local/tmp/scrcpy-server.jar',
'1.25',
new AdbScrcpyOptions1_22(scrcpyOption)
)
// get the video stream
const videoStream = await _client?.videoStream
// create a decoder
const _decoder = new WebCodecsDecoder(ScrcpyVideoCodecId.H264)
scrcpyScreenRef.current?.appendChild(_decoder.renderer)
_decoder.renderer.style.width = '100%'
_decoder.renderer.style.height = '100%'
// pipe the video stream to the decoder
videoStream?.stream.pipeTo(_decoder.writable)
// if client has controlMessageWriter, Inject mouse and button events
if (_client.controlMessageWriter) {
_decoder.renderer.addEventListener('mousedown', e => {
// client width and height 450 x 800
const react = _decoder.renderer.getBoundingClientRect()
// normalize to _decoder.renderer.width and height 1080 x 1920
const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width
const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height
//console.log('mouse down at ' + x + ' ' + y)
_client.controlMessageWriter?.injectTouch({
action: AndroidMotionEventAction.Down,
pointerId: ScrcpyPointerId.Mouse | ScrcpyPointerId.Finger,
pointerX: x,
pointerY: y,
pressure: 1,
screenWidth: _decoder.renderer.width,
screenHeight: _decoder.renderer.height,
buttons: 0,
actionButton: 0
})
})
_decoder.renderer.addEventListener('mouseup', e => {
// client width and height 450 x 800
const react = _decoder.renderer.getBoundingClientRect()
// normalize to _decoder.renderer.width and height 1080 x 1920
const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width
const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height
//console.log('mouse up at ' + x + ' ' + y)
_client.controlMessageWriter?.injectTouch({
action: AndroidMotionEventAction.Up,
pointerId: ScrcpyPointerId.Mouse,
pointerX: x,
pointerY: y,
pressure: 1,
screenWidth: _decoder.renderer.width,
screenHeight: _decoder.renderer.height,
buttons: 0,
actionButton: 0
})
})
_decoder.renderer.addEventListener('mousemove', e => {
// client width and height 450 x 800
const react = _decoder.renderer.getBoundingClientRect()
// normalize to _decoder.renderer.width and height 1080 x 1920
const x = ((e.clientX - react.left) * _decoder.renderer.width) / react.width
const y = ((e.clientY - react.top) * _decoder.renderer.height) / react.height
//console.log('mouse move at ' + x + ' ' + y)
_client.controlMessageWriter?.injectTouch({
action: AndroidMotionEventAction.Move,
pointerId: ScrcpyPointerId.Mouse,
pointerX: x,
pointerY: y,
pressure: 1,
screenWidth: _decoder.renderer.width,
screenHeight: _decoder.renderer.height,
buttons: 0,
actionButton: 0
})
})
}
setDecoder(_decoder)
setClient(_client)
}, [adb, scrcpyScreenRef])
function onHomeClickHandler() {
client?.controlMessageWriter?.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode: AndroidKeyCode.AndroidHome,
metaState: 0,
repeat: 0
})
}
function onBackClickHandler() {
client?.controlMessageWriter?.injectKeyCode({
action: AndroidKeyEventAction.Up,
keyCode: AndroidKeyCode.AndroidBack,
metaState: 0,
repeat: 0
})
}
function disconnectScrcpy() {
// clean ref
if (scrcpyScreenRef.current && scrcpyScreenRef.current.children.length > 0) {
while (scrcpyScreenRef.current.firstChild) {
scrcpyScreenRef.current.removeChild(scrcpyScreenRef.current.firstChild)
}
}
decoder?.dispose()
client?.close()
setClient(undefined)
setDecoder(undefined)
}
}, [decoder])
return (
<Card>
@ -271,54 +40,44 @@ export const ScrcpyTab: React.FC<ScrcpyTabProps> = memo(({ adb }) => {
<CardTitle>Scrcpy</CardTitle>
<CardDescription>Stream and control your Android device from your computer</CardDescription>
</CardHeader>
<CardContent className="flex w-full justify-around items-start">
<CardContent className="flex w-full items-start justify-around">
<div>
<div className="w-[450px] max-w-[450px] h-[800px] max-h-[800px] bg-slate-700" ref={scrcpyScreenRef} />
<div className="flex pt-3 justify-center items-center space-x-4 w-[450px]">
<Button onClick={onHomeClickHandler} variant={'outline'} className="flex-1">
<div className="h-[800px] max-h-[800px] w-[450px] max-w-[450px] bg-slate-700" ref={scrcpyScreenRef} />
<div className="flex w-[450px] items-center justify-center space-x-4 pt-3">
<Button onClick={onHomeClick} variant={'outline'} className="flex-1">
Home
</Button>
<Button onClick={onBackClickHandler} variant={'outline'} className="flex-1">
<Button onClick={onBackClick} variant={'outline'} className="flex-1">
Back
</Button>
</div>
</div>
<div className="flex flex-col space-y-4 w-full px-5">
<div className="flex w-full flex-col space-y-4 px-5">
<Card>
<CardHeader>
<CardTitle>Control</CardTitle>
</CardHeader>
<CardContent>
<div className="flex space-x-4 items-center">
{client ? (
<div className="flex items-center space-x-4">
{scrcpyClient ? (
<Button onClick={disconnectScrcpy} variant="destructive">
Disconnect
</Button>
) : (
<Button onClick={connectScrcpy} variant="default">
<Button
onClick={() => {
connectScrcpy(adb)
}}
variant="default"
>
Connect
</Button>
)}
</div>
</CardContent>
</Card>
{/* logcat card */}
<Card>
<CardHeader>
<CardTitle>Logcat</CardTitle>
</CardHeader>
<CardContent>
<div className="flex space-x-4 items-center">
<div className="w-full h-96 bg-slate-700" ref={logcatRef} />
</div>
</CardContent>
</Card>
</div>
</CardContent>
<CardFooter>
<Button>Save changes</Button>
</CardFooter>
</Card>
)
})

View file

@ -1,84 +1,27 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { encodeUtf8, type AdbSubprocessProtocol, type Adb } from '@yume-chan/adb'
import { Consumable, WritableStream } from '@yume-chan/stream-extra'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import useShellAndroid from '@/hooks/shell-android'
import { type Adb } from '@yume-chan/adb'
import { memo, useEffect, useRef } from 'react'
import { type Terminal } from 'xterm'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
import { useShallow } from 'zustand/react/shallow'
interface ShellTabProps {
adb: Adb | undefined
}
export const ShellTab: React.FC<ShellTabProps> = memo(({ adb }) => {
const [process, setProcess] = useState<AdbSubprocessProtocol | undefined>()
const [terminal, setTerminal] = useState<Terminal | undefined>()
const [reader, setReader] = useState<WritableStream<Uint8Array> | undefined>()
useEffect(() => {
if (adb) {
console.log('adb is connected')
console.log('creating terminal')
const terminal: Terminal = new Terminal()
terminal.options.cursorBlink = true
terminal.options.theme = {
background: '#1e1e1e',
foreground: '#d4d4d4'
}
console.log('creating process')
const _reader = new WritableStream<Uint8Array>({
write(chunk) {
terminal.write(chunk)
}
})
adb.subprocess.shell('/data/data/com.termux/files/usr/bin/telnet localhost 45515').then(_process => {
_process.stdout.pipeTo(_reader)
const writer = _process.stdin.getWriter()
terminal.onData(data => {
const buffer = encodeUtf8(data)
const consumable = new Consumable(buffer)
writer.write(consumable)
})
setReader(_reader)
setProcess(_process)
setTerminal(terminal)
})
} else {
console.log('adb is not connected')
if (process) {
process?.stdout.cancel()
process?.stderr.cancel()
process?.stdin.close()
process?.kill()
}
setProcess(undefined)
setTerminal(undefined)
}
}, [adb])
const killProcess = useCallback(() => {
console.log('killing shell')
console.log(process)
if (process && terminal) {
terminal.write('exit\n')
reader?.close()
process.stderr.cancel()
process.stdin.close()
process.stdout.cancel()
process.kill()
}
}, [process])
const { terminal, startTerminal, killTerminal } = useShellAndroid(
useShallow(state => ({
terminal: state.terminal,
startTerminal: state.startTerminal,
killTerminal: state.killTerminal
}))
)
return (
<Card>
@ -88,10 +31,16 @@ export const ShellTab: React.FC<ShellTabProps> = memo(({ adb }) => {
</CardHeader>
<CardContent>
<div className="flex items-center justify-end py-3 w-full">
<div>
<Button variant={'destructive'} onClick={killProcess}>
Kill Shell
</Button>
<div className="space-x-5">
{terminal ? (
<Button variant={'destructive'} onClick={killTerminal}>
Terminate
</Button>
) : (
<Button variant={'default'} onClick={() => startTerminal(adb)}>
Start
</Button>
)}
</div>
</div>
{terminal ? (
@ -114,7 +63,6 @@ const ShellTerminal: React.FC<ShellTerminalProps> = ({ terminal }) => {
const shellRef = useRef<HTMLDivElement>(null)
useEffect(() => {
console.log(shellRef.current)
// check if shellRef is have child remove all
if (shellRef.current && shellRef.current.children.length > 0) {
for (const child of shellRef.current.children) {

View file

@ -13,7 +13,7 @@ import { Adb, AdbDaemonTransport } from '@yume-chan/adb'
import AdbWebCredentialStore from '@yume-chan/adb-credential-web'
import {
ADB_DEFAULT_DEVICE_FILTER,
type AdbDaemonWebUsbDevice,
AdbDaemonWebUsbDevice,
type AdbDaemonWebUsbDeviceManager
} from '@yume-chan/adb-daemon-webusb'
import { useState } from 'react'
@ -32,9 +32,11 @@ export const ToolBar: React.FC<ToolBarProps> = ({ manager, adb, device, setAdb,
const [version, setVersion] = useState<string>('')
async function createNewConnection() {
let selectedDevice
console.log(device)
let selectedDevice: AdbDaemonWebUsbDevice | undefined = undefined
if (!device) {
console.log('no device')
selectedDevice = await manager?.requestDevice({
filters: [
{
@ -53,9 +55,13 @@ export const ToolBar: React.FC<ToolBarProps> = ({ manager, adb, device, setAdb,
selectedDevice = device
}
// create transport and connect to device
let adb: Adb | null = null
let connection
try {
connection = await selectedDevice.connect()
if (selectedDevice instanceof AdbDaemonWebUsbDevice) {
connection = await selectedDevice.connect()
}
} catch (e) {
toast({
duration: 5000,
@ -66,24 +72,46 @@ export const ToolBar: React.FC<ToolBarProps> = ({ manager, adb, device, setAdb,
return
}
const credentialStore: AdbWebCredentialStore = new AdbWebCredentialStore()
if (connection) {
const credentialStore: AdbWebCredentialStore = new AdbWebCredentialStore()
const transport = await AdbDaemonTransport.authenticate({
serial: selectedDevice.serial,
connection: connection,
credentialStore: credentialStore
})
const transport = await AdbDaemonTransport.authenticate({
serial: selectedDevice.serial,
connection: connection,
credentialStore: credentialStore
})
const adb: Adb = new Adb(transport)
adb = new Adb(transport)
}
const name = await adb.getProp('ro.product.model')
const version = await adb.getProp('ro.build.version.release')
if (adb) {
const name = await adb.getProp('ro.product.model')
const version = await adb.getProp('ro.build.version.release')
setName(name)
setResolution(resolution)
setVersion(version)
setName(name)
setResolution(resolution)
setVersion(version)
setAdb(adb)
setAdb(adb)
}
}
async function connectAdbDaemon() {
if (!window.electronRuntime) {
toast({
duration: 5000,
variant: 'destructive',
title: 'Failed to connect to adb daemon',
description: 'This feature is only available in the desktop app'
})
return
}
// create connection
await window.ipcRenderer.invoke('adb')
const result = await window.ipcRenderer.invoke('adb:shell', 'ls')
console.log(result)
}
function onDisconnect() {
@ -121,6 +149,10 @@ export const ToolBar: React.FC<ToolBarProps> = ({ manager, adb, device, setAdb,
Connect
</Button>
)}
<Button variant={'default'} onClick={connectAdbDaemon}>
Connect Adb Daemon
</Button>
</div>
</div>
)

View file

@ -1,91 +0,0 @@
import { type ColumnDef } from '@tanstack/react-table'
import { type RecipeOverview } from '@/models/recipe/schema'
import { Checkbox } from '@/components/ui/checkbox'
import DataTableColumnHeader from './data-table-column-header'
import { Badge } from '@/components/ui/badge'
import DataTableRowActions from './data-table-row-actions'
import { type RecipeStatus, getRecipeStatusIcon } from '@/constants/recipe'
export const columns: ColumnDef<RecipeOverview>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false
},
{
accessorKey: 'productCode',
header: ({ column }) => <DataTableColumnHeader column={column} title="ProductCode" />,
cell: ({ row }) => <div className="w-[80px]">{row.getValue('productCode')}</div>,
enableSorting: false,
enableHiding: false
},
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const label = { label: 'Test Label' } // labels.find(label => label.value === row.getValue('label'))
return (
<div className="flex space-x-2">
{label && <Badge variant="outline">{label.label}</Badge>}
<span className="max-w-[500px] truncate font-medium">{row.getValue('name')}</span>
</div>
)
}
},
{
accessorKey: 'status',
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status: RecipeStatus = row.getValue('status')
const StatusIcon = getRecipeStatusIcon(status)
if (!status) {
return null
}
return (
<div className="flex w-[100px] items-center">
{<StatusIcon className="text-muted-foreground mr-2 h-4 w-4" />}
<span>{}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
}
},
{
accessorKey: 'lastUpdated',
header: ({ column }) => <DataTableColumnHeader column={column} title="Last Updated" />,
cell: ({ row }) => {
return (
<div className="flex items-center">
<span>{row.getValue('lastUpdated')}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
}
},
{
id: 'actions',
cell: ({ row }) => <DataTableRowActions row={row} />
}
]

View file

@ -0,0 +1,9 @@
const RecipeForm: React.FC = () => {
return (
<div>
<h1>Recipe Form</h1>
</div>
)
}
export default RecipeForm

View file

@ -0,0 +1,119 @@
import { type ColumnDef } from '@tanstack/react-table'
import { type RecipeDashboard } from '@/models/recipe/schema'
import { Checkbox } from '@/components/ui/checkbox'
import DataTableColumnHeader from './data-table-column-header'
// import { Badge } from '@/components/ui/badge'
import DataTableRowActions from './data-table-row-actions'
import { CheckCircledIcon, CrossCircledIcon } from '@radix-ui/react-icons'
import * as dateFormat from 'date-fns'
import { DateRange } from 'react-day-picker'
export const columns: ColumnDef<RecipeDashboard>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false
},
{
accessorKey: 'productCode',
header: ({ column }) => <DataTableColumnHeader column={column} title="ProductCode" />,
cell: ({ row }) => <div>{row.getValue('productCode')}</div>,
enableHiding: false,
enableGlobalFilter: true,
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
}
},
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
//const label = { label: 'Test Label' } // labels.find(label => label.value === row.getValue('label'))
return (
<div className="flex space-x-2">
{/* {label && <Badge variant="outline">{label.label}</Badge>} */}
<span className="max-w-[500px] truncate font-medium">{row.getValue('name')}</span>
</div>
)
},
enableGlobalFilter: true,
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
}
},
{
accessorKey: 'nameEng',
header: ({ column }) => <DataTableColumnHeader column={column} title="Name ENG" />,
cell: ({ row }) => {
return (
<div className="flex items-center">
<span>{row.getValue('nameEng')}</span>
</div>
)
},
enableGlobalFilter: true,
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
}
},
{
accessorKey: 'inUse',
header: ({ column }) => <DataTableColumnHeader column={column} title="Active" />,
cell: ({ row }) => {
return (
<div className="flex items-center">
<span>
{row.getValue('inUse') ? (
<CheckCircledIcon className="h-6 w-6 text-green-700" />
) : (
<CrossCircledIcon className="h-6 w-6 text-red-700" />
)}
</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
}
},
{
accessorKey: 'lastUpdated',
header: ({ column }) => <DataTableColumnHeader column={column} title="Last Updated" isDate />,
cell: ({ row }) => {
return (
<div className="flex items-center">
<span>{dateFormat.format(row.getValue('lastUpdated'), 'dd-MM-yyyy HH:mm:ss')}</span>
</div>
)
},
filterFn: (_row, _id, _value) => {
const value = _value as DateRange
const rowValue = _row.getValue(_id) as Date
return (
dateFormat.isAfter(rowValue, value.from || dateFormat.add(rowValue, { days: 1 })) &&
dateFormat.isBefore(rowValue, value.to || dateFormat.sub(rowValue, { days: 1 }))
)
}
},
{
id: 'actions',
cell: ({ row }) => <DataTableRowActions row={row} />
}
]

View file

@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
DropdownMenu,
DropdownMenuContent,
@ -7,28 +8,57 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
import { ArrowDownIcon, ArrowUpIcon, CalendarIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { DateRange } from 'react-day-picker'
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
isDate?: boolean
}
const DataTableColumnHeader = <TData, TValue>({
column,
title,
isDate,
className
}: DataTableColumnHeaderProps<TData, TValue>) => {
if (!column.getCanSort()) {
if (!column.getCanSort() && !isDate) {
return <div className={cn(className)}>{title}</div>
}
if (isDate) {
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="data-[state=open]:bg-accent -ml-3 h-8">
<span>{title}</span>
<CalendarIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
<Calendar
mode="range"
defaultMonth={new Date('2022-01-01')}
selected={column.getFilterValue() as DateRange}
onSelect={date => column.setFilterValue(date)}
numberOfMonths={2}
disabled={date => date > new Date() || date < new Date('1900-01-01')}
initialFocus
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
<Button variant="ghost" size="sm" className="data-[state=open]:bg-accent -ml-3 h-8">
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className="ml-2 h-4 w-4" />
@ -41,16 +71,16 @@ const DataTableColumnHeader = <TData, TValue>({
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
<ArrowUpIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
<ArrowDownIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
<EyeNoneIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -15,21 +15,19 @@ import {
CommandSeparator
} from '@/components/ui/command'
interface DataTableFacetedFilterProps<TData, TValue> {
interface DataTableFacetedFilterProps<TData, TValue, TOption> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
options: TOption extends { value: string; label: string; icon?: React.ComponentType<{ className?: string }> }
? TOption[]
: never
}
const DataTableFacetedFilter = <TData, TValue>({
const DataTableFacetedFilter = <TData, TValue, TOption>({
column,
title,
options
}: DataTableFacetedFilterProps<TData, TValue>) => {
}: DataTableFacetedFilterProps<TData, TValue, TOption>) => {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
@ -87,13 +85,13 @@ const DataTableFacetedFilter = <TData, TValue>({
>
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible'
)}
>
<CheckIcon className={cn('h-4 w-4')} />
</div>
{option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />}
{option.icon && <option.icon className="text-muted-foreground mr-2 h-4 w-4" />}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">

View file

@ -1,5 +1,5 @@
import { type Row } from '@tanstack/react-table'
import { taskSchema } from '@/models/recipe/schema'
import { recipeDashboardSchema } from '@/models/recipe/schema'
import {
DropdownMenu,
DropdownMenuContent,
@ -22,7 +22,7 @@ interface DataTableRowActionsProps<TData> {
}
const DataTableRowActions = <TData,>({ row }: DataTableRowActionsProps<TData>) => {
const task = taskSchema.parse(row.original)
const task = recipeDashboardSchema.parse(row.original)
return (
<DropdownMenu>
@ -35,7 +35,6 @@ const DataTableRowActions = <TData,>({ row }: DataTableRowActionsProps<TData>) =
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>

View file

@ -1,35 +1,62 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { type Table } from '@tanstack/react-table'
import { statuses, priorities } from '@/models/data'
// import { statuses, priorities } from '@/models/data'
import DataTableFacetedFilter from './data-table-faceted-filter'
import DataTableViewOptions from './data-table-view-options'
import { Cross2Icon } from '@radix-ui/react-icons'
import { Cross2Icon, ReloadIcon } from '@radix-ui/react-icons'
import useRecipeDashboard from '@/hooks/recipe-dashboard'
import { useQuery } from '@tanstack/react-query'
interface DataTableToolbarProps<TData> {
table: Table<TData>
globalFilter: string
setGlobalFilter: (value: string) => void
}
const DataTableToolbar = <TData,>({ table }: DataTableToolbarProps<TData>) => {
const DataTableToolbar = <TData,>({ table, globalFilter, setGlobalFilter }: DataTableToolbarProps<TData>) => {
const isFiltered = table.getState().columnFilters.length > 0
const getMaterial = useRecipeDashboard(state => state.getMaterials)
const {
data: materials,
isLoading,
isError
} = useQuery({
queryKey: ['materials'],
queryFn: () => getMaterial()
})
return (
<div className="flex items-center justify-between">
<div className="flex w-full flex-col space-y-4 rounded-lg bg-white p-3 shadow-md">
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Filter tasks..."
value={(table.getColumn('title')?.getFilterValue() as string) ?? ''}
onChange={event => table.getColumn('title')?.setFilterValue(event.target.value)}
placeholder="Filter recipe..."
value={globalFilter}
onChange={event => setGlobalFilter(event.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
{table.getColumn('status') && (
{!isLoading && !isError ? (
<DataTableFacetedFilter
title="Material"
options={
materials as { value: string; label: string; icon?: React.ComponentType<{ className?: string }> }[]
}
/>
) : !isError ? (
<ReloadIcon className="text-muted-foreground mr-2 h-4 w-4 animate-spin" />
) : (
<span className="text-muted-foreground">Error loading materials</span>
)}
{/* {table.getColumn('status') && (
<DataTableFacetedFilter column={table.getColumn('status')} title="Status" options={statuses} />
)}
{table.getColumn('priority') && (
<DataTableFacetedFilter column={table.getColumn('priority')} title="Priority" options={priorities} />
)}
)} */}
{isFiltered && (
<Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3">
Reset

View file

@ -1,4 +1,4 @@
import type { VisibilityState, ColumnFiltersState, SortingState, ColumnDef } from '@tanstack/react-table'
import type { VisibilityState, ColumnFiltersState, SortingState, ColumnDef, FilterFn } from '@tanstack/react-table'
import {
useReactTable,
getCoreRowModel,
@ -9,10 +9,25 @@ import {
getFacetedUniqueValues,
flexRender
} from '@tanstack/react-table'
import { rankItem } from '@tanstack/match-sorter-utils'
import { useState } from 'react'
import DataTableToolbar from './data-table-toolbar'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import DataTablePagination from './data-table-pagination'
import { ReloadIcon } from '@radix-ui/react-icons'
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value)
// Store the itemRank info
addMeta({
itemRank
})
// Return if the item should be filtered in/out
return itemRank.passed
}
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
@ -26,15 +41,23 @@ const DataTable = <TData, TValue>({ columns, data, isLoading }: DataTableProps<T
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState<string>('')
const table = useReactTable({
data,
columns,
filterFns: {
fuzzy: fuzzyFilter
},
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters
columnFilters,
globalFilter
},
globalFilterFn: fuzzyFilter,
onGlobalFilterChange: setGlobalFilter,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
@ -50,7 +73,7 @@ const DataTable = <TData, TValue>({ columns, data, isLoading }: DataTableProps<T
return (
<div className="space-y-4">
<DataTableToolbar table={table} />
<DataTableToolbar table={table} globalFilter={globalFilter} setGlobalFilter={setGlobalFilter} />
<div className="rounded-md border">
<Table>
<TableHeader>
@ -77,8 +100,9 @@ const DataTable = <TData, TValue>({ columns, data, isLoading }: DataTableProps<T
))
) : isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Loading...
<TableCell colSpan={columns.length} className="flex h-24 space-x-5 text-center">
<span>Loading...</span>
<ReloadIcon className="h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : (

View file

@ -0,0 +1,12 @@
import RecipeForm from './components/recipe-edit-components/recipe-form'
const RecipeEditPage: React.FC = () => {
return (
<div>
<h1>Edit Recipe</h1>
<RecipeForm />
</div>
)
}
export default RecipeEditPage

View file

@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query'
import { columns } from './components/recipe-table-components/columns'
import DataTable from './components/recipe-table-components/data-table'
import useRecipeDashboard from '@/hooks/recipe-dashboard'
import useLocalStorage from '@/hooks/localStorage'
const RecipesTablePage = () => {
const recipeQuery = useLocalStorage(state => state.recipeQuery)
const getRecipesDashboard = useRecipeDashboard(state => state.getRecipesDashboard)
const { data: recipeDashboardList, isLoading } = useQuery({
queryKey: ['recipe-overview'],
queryFn: () => getRecipesDashboard(recipeQuery)
})
return (
<div className="flex w-full flex-col gap-3">
<section>
<h1 className="text-3xl font-bold text-gray-900">Recipes</h1>
</section>
<section>
<DataTable data={recipeDashboardList ?? []} columns={columns} isLoading={isLoading} />
</section>
</div>
)
}
export default RecipesTablePage

View file

@ -1,24 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { columns } from './components/columns'
import DataTable from './components/data-table'
import { getRecipeOverview } from '@/hooks/recipe/get-recipe-overview'
const RecipesPage = () => {
const { data: recipeOverviewList, isLoading } = useQuery({
queryKey: ['recipe-overview'],
queryFn: () => getRecipeOverview()
})
return (
<div className="flex w-full flex-col gap-3">
<section>
<h1 className="text-3xl font-bold text-gray-900">Recipes</h1>
</section>
<section>
<DataTable data={recipeOverviewList ?? []} columns={columns} isLoading={isLoading} />
</section>
</div>
)
}
export default RecipesPage

View file

@ -0,0 +1,25 @@
import { Button } from '@/components/ui/button'
import useLocalStorage from '@/hooks/localStorage'
import { Link } from 'react-router-dom'
import { useShallow } from 'zustand/react/shallow'
const SelectCountryPage: React.FC = () => {
const { recipeQuery, setRecipeQuery } = useLocalStorage(
useShallow(state => ({
recipeQuery: state.recipeQuery,
setRecipeQuery: state.setRecipeQuery
}))
)
return (
<div>
<h1>SelectContryPage</h1>
<Button variant={'link'} onClick={() => setRecipeQuery({ countryID: 'tha', filename: 'coffeethai02_635.json' })}>
Thai
</Button>
{recipeQuery && <Link to={`/recipes/${recipeQuery?.countryID}/${recipeQuery?.filename}`}>Recipes</Link>}
</div>
)
}
export default SelectCountryPage