mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-28 13:40:29 +08:00
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import {
|
|
Button,
|
|
Card,
|
|
CardBody,
|
|
CardFooter,
|
|
Chip,
|
|
Dropdown,
|
|
DropdownItem,
|
|
DropdownMenu,
|
|
DropdownTrigger,
|
|
Progress,
|
|
Tooltip
|
|
} from '@heroui/react'
|
|
import { calcPercent, calcTraffic } from '@renderer/utils/calc'
|
|
import { IoMdMore, IoMdRefresh } from 'react-icons/io'
|
|
import dayjs from '@renderer/utils/dayjs'
|
|
import React, { Key, useEffect, useMemo, useState } from 'react'
|
|
import EditFileModal from './edit-file-modal'
|
|
import EditInfoModal from './edit-info-modal'
|
|
import { useSortable } from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { openFile } from '@renderer/utils/ipc'
|
|
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
interface Props {
|
|
info: IProfileItem
|
|
isCurrent: boolean
|
|
addProfileItem: (item: Partial<IProfileItem>) => Promise<void>
|
|
updateProfileItem: (item: IProfileItem) => Promise<void>
|
|
removeProfileItem: (id: string) => Promise<void>
|
|
mutateProfileConfig: () => void
|
|
onPress: () => Promise<void>
|
|
}
|
|
|
|
interface MenuItem {
|
|
key: string
|
|
label: string
|
|
showDivider: boolean
|
|
color: 'default' | 'danger'
|
|
className: string
|
|
}
|
|
const ProfileItem: React.FC<Props> = (props) => {
|
|
const { t } = useTranslation()
|
|
const {
|
|
info,
|
|
addProfileItem,
|
|
removeProfileItem,
|
|
mutateProfileConfig,
|
|
updateProfileItem,
|
|
onPress,
|
|
isCurrent
|
|
} = props
|
|
const extra = info?.extra
|
|
const usage = (extra?.upload ?? 0) + (extra?.download ?? 0)
|
|
const total = extra?.total ?? 0
|
|
const { appConfig, patchAppConfig } = useAppConfig()
|
|
const { profileDisplayDate = 'expire' } = appConfig || {}
|
|
const [updating, setUpdating] = useState(false)
|
|
const [selecting, setSelecting] = useState(false)
|
|
const [openInfoEditor, setOpenInfoEditor] = useState(false)
|
|
const [openFileEditor, setOpenFileEditor] = useState(false)
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform: tf,
|
|
transition,
|
|
isDragging
|
|
} = useSortable({
|
|
id: info.id
|
|
})
|
|
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
|
|
const [disableSelect, setDisableSelect] = useState(false)
|
|
|
|
const menuItems: MenuItem[] = useMemo(() => {
|
|
const list = [
|
|
{
|
|
key: 'edit-info',
|
|
label: t('profiles.editInfo.title'),
|
|
showDivider: false,
|
|
color: 'default',
|
|
className: ''
|
|
} as MenuItem,
|
|
{
|
|
key: 'edit-file',
|
|
label: t('profiles.editFile.title'),
|
|
showDivider: false,
|
|
color: 'default',
|
|
className: ''
|
|
} as MenuItem,
|
|
{
|
|
key: 'open-file',
|
|
label: t('profiles.openFile'),
|
|
showDivider: true,
|
|
color: 'default',
|
|
className: ''
|
|
} as MenuItem,
|
|
{
|
|
key: 'delete',
|
|
label: t('common.delete'),
|
|
showDivider: false,
|
|
color: 'danger',
|
|
className: 'text-danger'
|
|
} as MenuItem
|
|
]
|
|
if (info.home) {
|
|
list.unshift({
|
|
key: 'home',
|
|
label: t('profiles.home'),
|
|
showDivider: false,
|
|
color: 'default',
|
|
className: ''
|
|
} as MenuItem)
|
|
}
|
|
return list
|
|
}, [info, t])
|
|
|
|
const onMenuAction = async (key: Key): Promise<void> => {
|
|
switch (key) {
|
|
case 'edit-info': {
|
|
setOpenInfoEditor(true)
|
|
break
|
|
}
|
|
case 'edit-file': {
|
|
setOpenFileEditor(true)
|
|
break
|
|
}
|
|
case 'open-file': {
|
|
openFile('profile', info.id)
|
|
break
|
|
}
|
|
case 'delete': {
|
|
await removeProfileItem(info.id)
|
|
mutateProfileConfig()
|
|
break
|
|
}
|
|
|
|
case 'home': {
|
|
open(info.home)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (isDragging) {
|
|
setTimeout(() => {
|
|
setDisableSelect(true)
|
|
}, 200)
|
|
} else {
|
|
setTimeout(() => {
|
|
setDisableSelect(false)
|
|
}, 200)
|
|
}
|
|
}, [isDragging])
|
|
|
|
return (
|
|
<div
|
|
className="grid col-span-1"
|
|
style={{
|
|
position: 'relative',
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 'calc(infinity)' : undefined
|
|
}}
|
|
>
|
|
{openFileEditor && <EditFileModal id={info.id} onClose={() => setOpenFileEditor(false)} />}
|
|
{openInfoEditor && (
|
|
<EditInfoModal
|
|
item={info}
|
|
onClose={() => setOpenInfoEditor(false)}
|
|
updateProfileItem={updateProfileItem}
|
|
/>
|
|
)}
|
|
<Card
|
|
as="div"
|
|
fullWidth
|
|
isPressable
|
|
onPress={() => {
|
|
if (disableSelect) return
|
|
setSelecting(true)
|
|
onPress().finally(() => {
|
|
setSelecting(false)
|
|
})
|
|
}}
|
|
className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''}`}
|
|
>
|
|
<div ref={setNodeRef} {...attributes} {...listeners} className="w-full h-full">
|
|
<CardBody className="pb-1">
|
|
<div className="flex justify-between h-[32px]">
|
|
<h3
|
|
title={info?.name}
|
|
className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
|
>
|
|
{info?.name}
|
|
</h3>
|
|
<div className="flex">
|
|
{info.type === 'remote' && (
|
|
<Tooltip placement="left" content={dayjs(info.updated).fromNow()}>
|
|
<Button
|
|
isIconOnly
|
|
size="sm"
|
|
variant="light"
|
|
color="default"
|
|
disabled={updating}
|
|
onPress={async () => {
|
|
setUpdating(true)
|
|
await addProfileItem(info)
|
|
setUpdating(false)
|
|
}}
|
|
>
|
|
<IoMdRefresh
|
|
color="default"
|
|
className={`${isCurrent ? 'text-primary-foreground' : 'text-foreground'} text-[24px] ${updating ? 'animate-spin' : ''}`}
|
|
/>
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Dropdown>
|
|
<DropdownTrigger>
|
|
<Button isIconOnly size="sm" variant="light" color="default">
|
|
<IoMdMore
|
|
color="default"
|
|
className={`text-[24px] ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
|
/>
|
|
</Button>
|
|
</DropdownTrigger>
|
|
<DropdownMenu onAction={onMenuAction}>
|
|
{menuItems.map((item) => (
|
|
<DropdownItem
|
|
showDivider={item.showDivider}
|
|
key={item.key}
|
|
color={item.color}
|
|
className={item.className}
|
|
>
|
|
{item.label}
|
|
</DropdownItem>
|
|
))}
|
|
</DropdownMenu>
|
|
</Dropdown>
|
|
</div>
|
|
</div>
|
|
{info.type === 'remote' && extra && (
|
|
<div
|
|
className={`mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
|
>
|
|
<small>{`${calcTraffic(usage)}/${calcTraffic(total)}`}</small>
|
|
{profileDisplayDate === 'expire' ? (
|
|
<Button
|
|
size="sm"
|
|
variant="light"
|
|
className={`h-[20px] p-1 m-0 ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
|
onPress={async () => {
|
|
await patchAppConfig({ profileDisplayDate: 'update' })
|
|
}}
|
|
>
|
|
{extra.expire ? dayjs.unix(extra.expire).format('YYYY-MM-DD') : t('profiles.neverExpire')}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="light"
|
|
className={`h-[20px] p-1 m-0 ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
|
onPress={async () => {
|
|
await patchAppConfig({ profileDisplayDate: 'expire' })
|
|
}}
|
|
>
|
|
{dayjs(info.updated).fromNow()}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardBody>
|
|
<CardFooter className="pt-0">
|
|
{info.type === 'remote' && !extra && (
|
|
<div
|
|
className={`w-full mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
|
>
|
|
<Chip
|
|
size="sm"
|
|
variant="bordered"
|
|
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`}
|
|
>
|
|
{t('profiles.remote')}
|
|
</Chip>
|
|
<small>{dayjs(info.updated).fromNow()}</small>
|
|
</div>
|
|
)}
|
|
{info.type === 'local' && (
|
|
<div
|
|
className={`mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
|
>
|
|
<Chip
|
|
size="sm"
|
|
variant="bordered"
|
|
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`}
|
|
>
|
|
{t('profiles.local')}
|
|
</Chip>
|
|
</div>
|
|
)}
|
|
{extra && (
|
|
<Progress
|
|
className="w-full"
|
|
aria-label={t('profiles.trafficUsage')}
|
|
classNames={{
|
|
indicator: isCurrent ? 'bg-primary-foreground' : 'bg-foreground'
|
|
}}
|
|
value={calcPercent(extra?.upload, extra?.download, extra?.total)}
|
|
/>
|
|
)}
|
|
</CardFooter>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ProfileItem |