This commit is contained in:
IndieKKY
2023-05-17 16:37:56 +08:00
commit 858f83a45c
59 changed files with 8855 additions and 0 deletions

168
src/biz/Body.tsx Normal file
View File

@@ -0,0 +1,168 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {
setAutoScroll,
setAutoTranslate,
setCheckAutoScroll,
setCompact,
setFoldAll,
setNeedScroll,
setPage,
setSegmentFold
} from '../redux/envReducer'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {AiOutlineAim, FaRegArrowAltCircleDown, IoWarning, MdExpand, RiTranslate} from 'react-icons/all'
import classNames from 'classnames'
import toast from 'react-hot-toast'
import SegmentCard from './SegmentCard'
import {HEADER_HEIGHT, PAGE_SETTINGS, SUMMARIZE_ALL_THRESHOLD, TITLE_HEIGHT} from '../const'
import {FaClipboardList} from 'react-icons/fa'
import useTranslate from '../hooks/useTranslate'
const Body = () => {
const dispatch = useAppDispatch()
const noVideo = useAppSelector(state => state.env.noVideo)
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
const autoScroll = useAppSelector(state => state.env.autoScroll)
const segments = useAppSelector(state => state.env.segments)
const foldAll = useAppSelector(state => state.env.foldAll)
const envData = useAppSelector(state => state.env.envData)
const compact = useAppSelector(state => state.env.compact)
const apiKey = useAppSelector(state => state.env.envData.apiKey)
const floatKeyPointsSegIdx = useAppSelector(state => state.env.floatKeyPointsSegIdx)
const translateEnable = useAppSelector(state => state.env.envData.translateEnable)
const summarizeEnable = useAppSelector(state => state.env.envData.summarizeEnable)
const title = useAppSelector(state => state.env.title)
const {addSummarizeTask} = useTranslate()
const bodyRef = useRef<any>()
const curOffsetTop = useAppSelector(state => state.env.curOffsetTop)
const checkAutoScroll = useAppSelector(state => state.env.checkAutoScroll)
const needScroll = useAppSelector(state => state.env.needScroll)
const totalHeight = useAppSelector(state => state.env.totalHeight)
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const normalCallback = useCallback(() => {
dispatch(setCompact(false))
}, [dispatch])
const compactCallback = useCallback(() => {
dispatch(setCompact(true))
}, [dispatch])
const posCallback = useCallback(() => {
dispatch(setNeedScroll(true))
}, [dispatch])
const onSummarizeAll = useCallback(() => {
if (!apiKey) {
dispatch(setPage(PAGE_SETTINGS))
toast.error('需要先设置ApiKey!')
return
}
const segments_ = []
for (const segment of segments ?? []) {
const summary = segment.summaries[curSummaryType]
if (!summary || summary.status === 'init' || (summary.status === 'done' && summary.error)) {
segments_.push(segment)
}
}
if (segments_.length === 0) {
toast.error('没有可总结的段落!')
return
}
if (segments_.length < SUMMARIZE_ALL_THRESHOLD || confirm(`确定总结${segments_.length}个段落?`)) {
for (const segment of segments_) {
addSummarizeTask(title, curSummaryType, segment).catch(console.error)
}
toast.success(`已添加${segments_.length}个总结任务!`)
}
}, [addSummarizeTask, apiKey, curSummaryType, dispatch, segments, title])
const onFoldAll = useCallback(() => {
dispatch(setFoldAll(!foldAll))
for (const segment of segments ?? []) {
dispatch(setSegmentFold({
segmentStartIdx: segment.startIdx,
fold: !foldAll
}))
}
}, [dispatch, foldAll, segments])
const toggleAutoTranslateCallback = useCallback(() => {
if (envData.apiKey) {
dispatch(setAutoTranslate(!autoTranslate))
} else {
dispatch(setPage(PAGE_SETTINGS))
toast.error('需要先设置ApiKey!')
}
}, [autoTranslate, dispatch, envData.apiKey])
const onEnableAutoScroll = useCallback(() => {
dispatch(setAutoScroll(true))
dispatch(setNeedScroll(true))
}, [dispatch])
const onWheel = useCallback(() => {
if (autoScroll) {
dispatch(setAutoScroll(false))
}
}, [autoScroll, dispatch])
// 自动滚动
useEffect(() => {
if (checkAutoScroll && curOffsetTop && autoScroll && !needScroll) {
if (bodyRef.current.scrollTop <= curOffsetTop - bodyRef.current.offsetTop - (totalHeight-120) + (floatKeyPointsSegIdx != null ? 100 : 0) ||
bodyRef.current.scrollTop >= curOffsetTop - bodyRef.current.offsetTop - 40 - 10
) {
dispatch(setNeedScroll(true))
dispatch(setCheckAutoScroll(false))
console.debug('need scroll')
}
}
}, [autoScroll, checkAutoScroll, curOffsetTop, dispatch, floatKeyPointsSegIdx, needScroll, totalHeight])
return <div className='relative'>
<div className='absolute top-1 left-6 flex-center gap-1'>
<AiOutlineAim className='cursor-pointer' onClick={posCallback} title='滚动到视频位置'/>
{segments != null && segments.length > 1 &&
<MdExpand className={classNames('cursor-pointer', foldAll ? 'text-accent' : '')} onClick={onFoldAll}
title='展开/折叠全部'/>}
</div>
<div className='flex justify-center'>
<div className='tabs'>
<a className={classNames('tab tab-sm tab-bordered', !compact && 'tab-active')}
onClick={normalCallback}></a>
<a className={classNames('tab tab-sm tab-bordered', compact && 'tab-active')}
onClick={compactCallback}></a>
</div>
</div>
<div className='absolute top-1 right-6'>
{translateEnable && <div className='tooltip tooltip-left cursor-pointer' data-tip='点击切换自动翻译'
onClick={toggleAutoTranslateCallback}>
<RiTranslate className={autoTranslate ? 'text-accent' : ''}/>
</div>}
{summarizeEnable &&
<div className='tooltip tooltip-left cursor-pointer z-[100] ml-2' data-tip='总结全部' onClick={onSummarizeAll}>
<FaClipboardList/>
</div>}
{noVideo && <div className='tooltip tooltip-left ml-2' data-tip='当前浏览器不支持视频跳转'>
<IoWarning className='text-warning'/>
</div>}
</div>
{!autoScroll && <div
className='absolute z-[999] top-[96px] right-6 tooltip tooltip-left cursor-pointer rounded-full bg-primary/25 hover:bg-primary/75 text-primary-content p-1.5 text-xl'
data-tip='开启自动滚动'
onClick={onEnableAutoScroll}>
<FaRegArrowAltCircleDown className={autoScroll ? 'text-accent' : ''}/>
</div>}
<div ref={bodyRef} onWheel={onWheel}
className={classNames('flex flex-col gap-1.5 overflow-y-auto select-text scroll-smooth', floatKeyPointsSegIdx != null && 'pb-[100px]')}
style={{
height: `${totalHeight - HEADER_HEIGHT - TITLE_HEIGHT}px`
}}
>
{segments?.map((segment, segmentIdx) => <SegmentCard key={segment.startIdx} segment={segment} segmentIdx={segmentIdx} bodyRef={bodyRef}/>)}
</div>
</div>
}
export default Body

View File

@@ -0,0 +1,33 @@
import React, {useMemo} from 'react'
import {useAppSelector} from '../hooks/redux'
import {getDisplay, getTransText} from '../util/biz_util'
import classNames from 'classnames'
const CompactSegmentItem = (props: {
item: {
from: number
to: number
content: string
}
idx: number
isIn: boolean
last: boolean
moveCallback: (event: any) => void
}) => {
const {item, idx, last, isIn, moveCallback} = props
const transResult = useAppSelector(state => state.env.transResults[idx])
const envData = useAppSelector(state => state.env.envData)
const fontSize = useAppSelector(state => state.env.envData.fontSize)
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
const transText = useMemo(() => getTransText(transResult, envData.hideOnDisableAutoTranslate, autoTranslate), [autoTranslate, envData.hideOnDisableAutoTranslate, transResult])
const display = useMemo(() => getDisplay(envData.transDisplay, item.content, transText), [envData.transDisplay, item.content, transText])
return <div className={classNames('inline', fontSize === 'large'?'text-sm':'text-xs')}>
<span className={'pl-1 pr-0.5 py-0.5 cursor-pointer rounded-sm hover:bg-base-200'} onClick={moveCallback}>
<text className={classNames('font-medium', isIn ? 'text-primary underline' : '')}>{display.main}</text>
{display.sub && <text className='desc'>({display.sub})</text>}</span>
<span>{!last && ', '}</span>
</div>
}
export default CompactSegmentItem

99
src/biz/Header.tsx Normal file
View File

@@ -0,0 +1,99 @@
import {IoIosArrowUp} from 'react-icons/all'
import {useCallback} from 'react'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {find, remove} from 'lodash-es'
import {setCurFetched, setCurInfo, setData, setInfos, setUploadedTranscript} from '../redux/envReducer'
import MoreBtn from './MoreBtn'
import classNames from 'classnames'
import {parseTranscript} from '../util/biz_util'
const Header = (props: {
foldCallback: () => void
}) => {
const {foldCallback} = props
const dispatch = useAppDispatch()
const infos = useAppSelector(state => state.env.infos)
const curInfo = useAppSelector(state => state.env.curInfo)
const fold = useAppSelector(state => state.env.fold)
const uploadedTranscript = useAppSelector(state => state.env.uploadedTranscript)
const upload = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.vtt,.srt'
input.onchange = (e: any) => {
const file = e.target.files[0]
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target?.result
if (text) {
const infos_ = [...(infos??[])]
// const blob = new Blob([text], {type: 'text/plain'})
// const url = URL.createObjectURL(blob)
// remove old if exist
remove(infos_, {id: 'uploaded'})
// add new
const tarInfo = {id: 'uploaded', subtitle_url: 'uploaded', lan_doc: '上传的字幕'}
infos_.push(tarInfo)
// set
const transcript = parseTranscript(file.name, text)
dispatch(setInfos(infos_))
dispatch(setCurInfo(tarInfo))
dispatch(setCurFetched(true))
dispatch(setUploadedTranscript(transcript))
dispatch(setData(transcript))
}
}
reader.readAsText(file)
}
input.click()
}, [dispatch, infos])
const selectCallback = useCallback((e: any) => {
if (e.target.value === 'upload') {
upload()
return
}
const tarInfo = find(infos, {subtitle_url: e.target.value})
if (curInfo?.id !== tarInfo?.id) {
dispatch(setCurInfo(tarInfo))
if (tarInfo && tarInfo.subtitle_url === 'uploaded') {
dispatch(setCurFetched(true))
dispatch(setData(uploadedTranscript))
} else {
dispatch(setCurFetched(false))
}
}
}, [curInfo?.id, dispatch, infos, upload, uploadedTranscript])
const preventCallback = useCallback((e: any) => {
e.stopPropagation()
}, [])
const onUpload = useCallback((e: any) => {
e.stopPropagation()
upload()
}, [upload])
return <div className='rounded-[6px] bg-[#f1f2f3] dark:bg-base-100 h-[44px] flex justify-between items-center cursor-pointer' onClick={foldCallback}>
<div className='shrink-0 flex items-center'>
<span className='shrink-0 text-[15px] font-medium pl-[16px] pr-[14px]'></span>
<MoreBtn placement={'right-start'}/>
</div>
<div className='flex gap-0.5 items-center mr-[16px]'>
{(infos == null) || infos.length <= 0
?<div className='text-xs desc'>
<button className='btn btn-xs btn-link' onClick={onUpload}>(vtt/srt)</button>
()
</div>
:<select disabled={!infos || infos.length <= 0} className='select select-ghost select-xs line-clamp-1' value={curInfo?.subtitle_url} onChange={selectCallback} onClick={preventCallback}>
{infos?.map((item: any) => <option key={item.id} value={item.subtitle_url}>{item.lan_doc}</option>)}
<option key='upload' value='upload'>(vtt/srt)</option>
</select>}
<IoIosArrowUp className={classNames('shrink-0 desc transform ease-in duration-300', fold?'rotate-180':'')}/>
</div>
</div>
}
export default Header

278
src/biz/MoreBtn.tsx Normal file
View File

@@ -0,0 +1,278 @@
import React, {MouseEvent, useCallback, useContext, useRef, useState} from 'react'
import {useClickAway} from 'ahooks'
import {
AiFillWechat,
BsFillChatDotsFill,
FiMoreVertical,
ImDownload3,
IoMdSettings,
RiFileCopy2Line
} from 'react-icons/all'
import Popover from '../components/Popover'
import {Placement} from '@popperjs/core/lib/enums'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {setDownloadType, setEnvData, setPage} from '../redux/envReducer'
import {EventBusContext} from '../Router'
import {EVENT_EXPAND, PAGE_SETTINGS} from '../const'
import {formatSrtTime, formatTime, formatVttTime} from '../util/util'
import {downloadText, openUrl} from '@kky002/kky-util'
import toast from 'react-hot-toast'
import {getSummarize} from '../util/biz_util'
interface Props {
placement: Placement
}
const DownloadTypes = [
{
type: 'text',
name: '列表',
},
{
type: 'textWithTime',
name: '列表(带时间)',
},
{
type: 'article',
name: '文章',
},
{
type: 'srt',
name: 'srt',
},
{
type: 'vtt',
name: 'vtt',
},
{
type: 'json',
name: '原始json',
},
{
type: 'summarize',
name: '总结',
},
]
const MoreBtn = (props: Props) => {
const {placement} = props
const dispatch = useAppDispatch()
const moreRef = useRef(null)
const data = useAppSelector(state => state.env.data)
const envReady = useAppSelector(state => state.env.envReady)
const envData = useAppSelector(state => state.env.envData)
const downloadType = useAppSelector(state => state.env.downloadType)
const [moreVisible, setMoreVisible] = useState(false)
const eventBus = useContext(EventBusContext)
const segments = useAppSelector(state => state.env.segments)
const title = useAppSelector(state => state.env.title)
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const downloadCallback = useCallback((download: boolean) => {
if (data == null) {
return
}
let s, fileName
if (!downloadType || downloadType === 'text') {
s = ''
for (const item of data.body) {
s += item.content + '\n'
}
fileName = 'download.txt'
} else if (downloadType === 'textWithTime') {
s = ''
for (const item of data.body) {
s += formatTime(item.from) + ' ' + item.content + '\n'
}
fileName = 'download.txt'
} else if (downloadType === 'article') {
s = ''
for (const item of data.body) {
s += item.content + ', '
}
s = s.substring(0, s.length - 1) // remove last ','
fileName = 'download.txt'
} else if (downloadType === 'srt') {
/**
* 1
* 00:05:00,400 --> 00:05:15,300
* This is an example of
* a subtitle.
*
* 2
* 00:05:16,400 --> 00:05:25,300
* This is an example of
* a subtitle - 2nd subtitle.
*/
s = ''
for (const item of data.body) {
const ss = (item.idx + 1) + '\n' + formatSrtTime(item.from) + ' --> ' + formatSrtTime(item.to) + '\n' + ((item.content?.trim()) ?? '') + '\n\n'
s += ss
}
s = s.substring(0, s.length - 1)// remove last '\n'
fileName = 'download.srt'
} else if (downloadType === 'vtt') {
/**
* WEBVTT title
*
* 1
* 00:05:00.400 --> 00:05:15.300
* This is an example of
* a subtitle.
*
* 2
* 00:05:16.400 --> 00:05:25.300
* This is an example of
* a subtitle - 2nd subtitle.
*/
s = `WEBVTT ${title ?? ''}\n\n`
for (const item of data.body) {
const ss = (item.idx + 1) + '\n' + formatVttTime(item.from) + ' --> ' + formatVttTime(item.to) + '\n' + ((item.content?.trim()) ?? '') + '\n\n'
s += ss
}
s = s.substring(0, s.length - 1)// remove last '\n'
fileName = 'download.vtt'
} else if (downloadType === 'json') {
s = JSON.stringify(data)
fileName = 'download.json'
} else if (downloadType === 'summarize') {
const [success, content] = getSummarize(title, segments, curSummaryType)
if (!success) return
s = content
fileName = '总结.txt'
} else {
return
}
if (download) {
downloadText(s, fileName)
} else {
navigator.clipboard.writeText(s).then(() => {
toast.success('复制成功')
}).catch(console.error)
}
setMoreVisible(false)
}, [curSummaryType, data, downloadType, segments, title])
const downloadAudioCallback = useCallback(() => {
window.parent.postMessage({
type: 'downloadAudio',
}, '*')
}, [])
const selectCallback = useCallback((e: any) => {
dispatch(setDownloadType(e.target.value))
}, [dispatch])
const preventCallback = useCallback((e: any) => {
e.stopPropagation()
}, [])
const moreCallback = useCallback((e: MouseEvent) => {
e.stopPropagation()
if (!envData.flagDot) {
dispatch(setEnvData({
...envData,
flagDot: true,
}))
}
setMoreVisible(!moreVisible)
// 显示菜单时自动展开,防止菜单显示不全
if (!moreVisible) {
eventBus.emit({
type: EVENT_EXPAND
})
}
}, [dispatch, envData, eventBus, moreVisible])
useClickAway(() => {
setMoreVisible(false)
}, moreRef)
return <>
<div ref={moreRef} onClick={moreCallback}>
<div className='indicator flex items-center'>
{envReady && !envData.flagDot && <span className="indicator-item bg-secondary w-1.5 h-1.5 rounded-full"></span>}
<FiMoreVertical className='desc transform ease-in duration-300 hover:text-primary' title='更多'/>
</div>
</div>
{moreVisible &&
<Popover refElement={moreRef.current} className='bg-neutral text-neutral-content py-1 z-[1000]' options={{
placement
}}>
<ul className='menu menu-compact'>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
downloadCallback(false)
}}>
<RiFileCopy2Line className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
<select className='select select-ghost select-xs' value={downloadType} onChange={selectCallback}
onClick={preventCallback}>
{DownloadTypes?.map((item: any) => <option key={item.type} value={item.type}>{item.name}</option>)}
</select>
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
downloadCallback(true)
}}>
<ImDownload3 className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
<select className='select select-ghost select-xs' value={downloadType} onChange={selectCallback}
onClick={preventCallback}>
{DownloadTypes?.map((item: any) => <option key={item.type} value={item.type}>{item.name}</option>)}
</select>
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
downloadAudioCallback()
}}>
<ImDownload3 className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
(m4s)
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openUrl('https://jq.qq.com/?_wv=1027&k=RJyFABPF')
}}>
<BsFillChatDotsFill className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
QQ交流群(194536885)
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openUrl('https://static.ssstab.com/images/indiekky_public.png')
}}>
<AiFillWechat className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
(IndieKKY)
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
dispatch(setPage(PAGE_SETTINGS))
setMoreVisible(false)
e.preventDefault()
e.stopPropagation()
}}>
<IoMdSettings className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
</a>
</li>
</ul>
</Popover>}
</>
}
export default MoreBtn

View File

@@ -0,0 +1,35 @@
import React, {useMemo} from 'react'
import {formatTime} from '../util/util'
import {useAppSelector} from '../hooks/redux'
import {getDisplay, getTransText} from '../util/biz_util'
import classNames from 'classnames'
const NormalSegmentItem = (props: {
item: {
from: number
to: number
content: string
}
idx: number
isIn: boolean
moveCallback: (event: any) => void
}) => {
const {item, idx, isIn, moveCallback} = props
const transResult = useAppSelector(state => state.env.transResults[idx])
const envData = useAppSelector(state => state.env.envData)
const fontSize = useAppSelector(state => state.env.envData.fontSize)
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
const transText = useMemo(() => getTransText(transResult, envData.hideOnDisableAutoTranslate, autoTranslate), [autoTranslate, envData.hideOnDisableAutoTranslate, transResult])
const display = useMemo(() => getDisplay(envData.transDisplay, item.content, transText), [envData.transDisplay, item.content, transText])
return <div className={classNames('flex py-0.5 cursor-pointer rounded-sm hover:bg-base-200', fontSize === 'large'?'text-sm':'text-xs')}
onClick={moveCallback}>
<div className='desc w-[66px] flex justify-center'>{formatTime(item.from)}</div>
<div className={'flex-1'}>
<div className={classNames('font-medium', isIn ? 'text-primary underline' : '')}>{display.main}</div>
{display.sub && <div className='desc'>{display.sub}</div>}
</div>
</div>
}
export default NormalSegmentItem

239
src/biz/SegmentCard.tsx Normal file
View File

@@ -0,0 +1,239 @@
import React, {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {setCurSummaryType, setFloatKeyPointsSegIdx, setPage, setSegmentFold} from '../redux/envReducer'
import classNames from 'classnames'
import {FaClipboardList} from 'react-icons/fa'
import {PAGE_MAIN, PAGE_SETTINGS, SUMMARIZE_THRESHOLD} from '../const'
import useTranslate from '../hooks/useTranslate'
import {BsDashSquare, BsPlusSquare, CgFileDocument, GrOverview, RiFileCopy2Line} from 'react-icons/all'
import toast from 'react-hot-toast'
import {getLastTime, getSummaryStr, isSummaryEmpty, parseStrTimeToSeconds} from '../util/biz_util'
import {useInViewport} from 'ahooks'
import SegmentItem from './SegmentItem'
import {stopPopFunc} from '../util/util'
import useSubtitle from '../hooks/useSubtitle'
const SummarizeItemOverview = (props: {
segment: Segment
summary: OverviewSummary
segmentIdx: number
overviewItem: OverviewItem
idx: number
}) => {
const { segment, summary, segmentIdx, overviewItem, idx} = props
const {move} = useSubtitle()
const time = parseStrTimeToSeconds(overviewItem.time)
const currentTime = useAppSelector(state => state.env.currentTime)
const isIn = useMemo(() => {
if (currentTime != null) {
// check in current segment
if (segment.items?.length > 0) {
const startTime = segment.items[0].from
const lastTime = segment.items[segment.items.length - 1].to
if (currentTime >= startTime && currentTime < lastTime) {
// check in current overview item
const nextOverviewItem = summary.content?.[idx + 1]
const nextTime = (nextOverviewItem != null)?parseStrTimeToSeconds(nextOverviewItem.time):null
return currentTime >= time && (nextTime == null || currentTime < nextTime)
}
}
}
return false
}, [currentTime, idx, segment.items, summary.content, time])
const moveCallback = useCallback((event: any) => {
if (event.altKey) { // 复制
navigator.clipboard.writeText(overviewItem.key).catch(console.error)
} else {
move(time)
}
}, [overviewItem.key, move, time])
return <li className='flex items-center gap-1 relative cursor-pointer p-0.5 rounded-sm hover:bg-base-200' onClick={moveCallback}>
<span className='absolute left-[-16px] top-auto bottom-auto'>{overviewItem.emoji}</span>
<span className='bg-success/75 rounded-sm px-1'>{overviewItem.time}</span>
<span className={classNames(isIn ? 'text-primary underline' : '')}>{overviewItem.key}</span>
</li>
}
const Summarize = (props: {
segment: Segment
segmentIdx: number
summary?: Summary
float?: boolean
}) => {
const {segment, segmentIdx, summary, float} = props
const dispatch = useAppDispatch()
const apiKey = useAppSelector(state => state.env.envData.apiKey)
const fontSize = useAppSelector(state => state.env.envData.fontSize)
const title = useAppSelector(state => state.env.title)
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const {addSummarizeTask} = useTranslate()
const onGenerate = useCallback(() => {
if (apiKey) {
addSummarizeTask(title, curSummaryType, segment).catch(console.error)
} else {
dispatch(setPage(PAGE_SETTINGS))
toast.error('需要先设置ApiKey!')
}
}, [addSummarizeTask, apiKey, curSummaryType, dispatch, segment, title])
const onCopy = useCallback(() => {
if (summary != null) {
navigator.clipboard.writeText(getSummaryStr(summary)).then(() => {
toast.success('已复制到剪贴板!')
}).catch(console.error)
}
}, [summary])
return <div className='flex flex-col gap-0.5 relative'>
{(summary != null) && !isSummaryEmpty(summary) && <div className='absolute top-0 right-0'>
<RiFileCopy2Line className='desc cursor-pointer' onClick={onCopy}/>
</div>}
<div className='flex justify-center items-center'>
{summary?.type === 'overview' && (summary.content != null) &&
<ul className={classNames('font-medium list-none max-w-[90%]', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
{(summary.content).map((overviewItem: OverviewItem, idx: number) =>
<SummarizeItemOverview key={idx} idx={idx} summary={summary} overviewItem={overviewItem} segment={segment} segmentIdx={segmentIdx}/>)}
</ul>}
{summary?.type === 'keypoint' && (summary.content != null) &&
<ul className={classNames('font-medium list-disc max-w-[90%]', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
{summary.content?.map((keyPoint: string, idx: number) => <li key={idx}>{keyPoint}</li>)}
</ul>}
{summary?.type === 'brief' && (summary.content != null) &&
<div className={classNames('font-medium max-w-[90%]', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
{summary.content.summary}
</div>}
</div>
<div className='flex flex-col justify-center items-center'>
{segment.text.length < SUMMARIZE_THRESHOLD && <div className='desc-lighter text-xs'>.</div>}
{segment.text.length >= SUMMARIZE_THRESHOLD && ((summary == null) || summary.status !== 'done' || summary.error) && <button disabled={summary?.status === 'pending'}
className={classNames('btn btn-link btn-xs', summary?.status === 'pending' && 'loading')}
onClick={onGenerate}>{(summary == null) || summary.status === 'init' ? '点击生成' : (summary.status === 'pending' ? '生成中' : '重新生成')}</button>}
{summary?.error && <div className='text-xs text-error'>{summary?.error}</div>}
</div>
{!float && <div className='mx-2 my-1 h-[1px] bg-base-300'></div>}
</div>
}
const SegmentCard = (props: {
bodyRef: MutableRefObject<any>
segment: Segment
segmentIdx: number
}) => {
const {bodyRef, segment, segmentIdx} = props
const dispatch = useAppDispatch()
const summarizeRef = useRef<any>(null)
const [inViewport] = useInViewport(summarizeRef, {
root: bodyRef.current,
})
const segments = useAppSelector(state => state.env.segments)
const needScroll = useAppSelector(state => state.env.needScroll)
const curIdx = useAppSelector(state => state.env.curIdx)
const summarizeEnable = useAppSelector(state => state.env.envData.summarizeEnable)
const summarizeFloat = useAppSelector(state => state.env.envData.summarizeFloat)
const fold = useAppSelector(state => state.env.fold)
const page = useAppSelector(state => state.env.page)
const compact = useAppSelector(state => state.env.compact)
const floatKeyPointsSegIdx = useAppSelector(state => state.env.floatKeyPointsSegIdx)
const showCurrent = useMemo(() => curIdx != null && segment.startIdx <= curIdx && curIdx <= segment.endIdx, [curIdx, segment.endIdx, segment.startIdx])
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const summary = useMemo(() => {
const result = segment.summaries[curSummaryType]
if (result) {
return result
}
return undefined
}, [curSummaryType, segment.summaries])
const onFold = useCallback(() => {
dispatch(setSegmentFold({
segmentStartIdx: segment.startIdx,
fold: !segment.fold
}))
}, [dispatch, segment.fold, segment.startIdx])
// 检测设置floatKeyPointsSegIdx
useEffect(() => {
if (summarizeFloat) { // 已启用
if (!fold && page === PAGE_MAIN && showCurrent) { // 当前Card有控制权
if (!inViewport && (summary != null) && !isSummaryEmpty(summary)) {
dispatch(setFloatKeyPointsSegIdx(segment.startIdx))
} else {
dispatch(setFloatKeyPointsSegIdx())
}
}
}
}, [dispatch, fold, inViewport, page, segment.startIdx, showCurrent, summarizeFloat, summary])
const onSelBrief = useCallback(() => {
dispatch(setCurSummaryType('brief'))
}, [dispatch])
const onSelOverview = useCallback(() => {
dispatch(setCurSummaryType('overview'))
}, [dispatch])
const onSelKeypoint = useCallback(() => {
dispatch(setCurSummaryType('keypoint'))
}, [dispatch])
return <div
className={classNames('border border-base-300 bg-base-200/25 rounded flex flex-col m-1.5 p-1.5 gap-1 shadow', showCurrent && 'shadow-primary')}>
<div className='relative flex justify-center min-h-[20px]'>
{segments != null && segments.length > 1 &&
<div className='absolute left-0 top-0 bottom-0 text-xs select-none flex-center desc'>
{segment.fold
? <BsPlusSquare className='cursor-pointer' onClick={onFold}/> :
<BsDashSquare className='cursor-pointer' onClick={onFold}/>}
</div>}
{summarizeEnable && <div className="tabs">
<a className="tab tab-lifted tab-xs tab-disabled cursor-default"></a>
<a className={classNames('tab tab-lifted tab-xs', curSummaryType === 'brief' && 'tab-active')} onClick={onSelBrief}><CgFileDocument/></a>
<a className={classNames('tab tab-lifted tab-xs', curSummaryType === 'overview' && 'tab-active')} onClick={onSelOverview}><GrOverview/></a>
<a className={classNames('tab tab-lifted tab-xs', curSummaryType === 'keypoint' && 'tab-active')} onClick={onSelKeypoint}><FaClipboardList/></a>
<a className="tab tab-lifted tab-xs tab-disabled cursor-default"></a>
</div>}
<div
className='absolute right-0 top-0 bottom-0 text-xs desc-lighter select-none flex-center'>{getLastTime(segment.items[segment.items.length - 1].to - segment.items[0].from)}</div>
</div>
{summarizeEnable && <div ref={summarizeRef}>
<Summarize segment={segment} segmentIdx={segmentIdx} summary={summary}/>
</div>}
{!segment.fold
? <div>
{!compact && <div className='desc text-xs flex py-0.5'>
<div className='w-[66px] flex justify-center'></div>
<div className='flex-1'></div>
</div>}
{segment.items.map((item: TranscriptItem, idx: number) => <SegmentItem key={item.idx}
bodyRef={bodyRef}
item={item}
idx={segment.startIdx + idx}
isIn={curIdx === segment.startIdx + idx}
needScroll={needScroll && curIdx === segment.startIdx + idx}
last={idx === segment.items.length - 1}
/>)}
{segments != null && segments.length > 1 && <div className='flex justify-center'><a className='link text-xs'
onClick={onFold}>{segment.items.length}</a>
</div>}
</div>
: <div className='flex justify-center'><a className='link text-xs'
onClick={onFold}>{segment.items.length},</a>
</div>}
{floatKeyPointsSegIdx === segment.startIdx && <div
className='absolute bottom-0 left-0 right-0 z-[200] border-t bg-base-100 text-primary-content shadow max-h-[100px] overflow-y-auto scrollbar-hide'
onWheel={stopPopFunc}
>
<div className='bg-primary/50 p-2'>
<Summarize segment={segment} segmentIdx={segmentIdx} summary={summary} float/>
</div>
</div>}
</div>
}
export default SegmentCard

70
src/biz/SegmentItem.tsx Normal file
View File

@@ -0,0 +1,70 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import useSubtitle from '../hooks/useSubtitle'
import {setCheckAutoScroll, setCurOffsetTop, setNeedScroll} from '../redux/envReducer'
import NormalSegmentItem from './NormalSegmentItem'
import CompactSegmentItem from './CompactSegmentItem'
const SegmentItem = (props: {
bodyRef: any
item: {
from: number
to: number
content: string
}
idx: number
isIn: boolean
needScroll?: boolean
last: boolean
}) => {
const dispatch = useAppDispatch()
const {bodyRef, item, idx, isIn, needScroll, last} = props
const ref = useRef<any>()
const {move} = useSubtitle()
const compact = useAppSelector(state => state.env.compact)
const moveCallback = useCallback((event: any) => {
if (event.altKey) { // 复制
navigator.clipboard.writeText(item.content).catch(console.error)
} else {
move(item.from)
}
}, [item.content, item.from, move])
// 检测需要滚动进入视野
useEffect(() => {
if (needScroll) {
bodyRef.current.scrollTop = ref.current.offsetTop - bodyRef.current.offsetTop - 40
dispatch(setNeedScroll(false))
}
}, [dispatch, needScroll, bodyRef])
// 进入时更新当前offsetTop
useEffect(() => {
if (isIn) {
dispatch(setCurOffsetTop(ref.current.offsetTop))
dispatch(setCheckAutoScroll(true))
}
}, [dispatch, isIn])
return <span ref={ref}>
{compact
? <CompactSegmentItem
item={item}
idx={idx}
isIn={isIn}
last={last}
moveCallback={moveCallback}
/>
:
<NormalSegmentItem
item={item}
idx={idx}
isIn={isIn}
moveCallback={moveCallback}
/>
}
</span>
}
export default SegmentItem

277
src/biz/Settings.tsx Normal file
View File

@@ -0,0 +1,277 @@
import React, {PropsWithChildren, useCallback, useMemo, useState} from 'react'
import {setEnvData, setPage} from '../redux/envReducer'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {
HEADER_HEIGHT,
LANGUAGE_DEFAULT,
LANGUAGES,
PAGE_MAIN,
SERVER_URL_THIRD,
SUMMARIZE_LANGUAGE_DEFAULT,
TRANSLATE_FETCH_DEFAULT,
TRANSLATE_FETCH_MAX,
TRANSLATE_FETCH_MIN,
TRANSLATE_FETCH_STEP,
WORDS_DEFAULT,
WORDS_MAX,
WORDS_MIN,
WORDS_STEP
} from '../const'
import {IoWarning} from 'react-icons/all'
import classNames from 'classnames'
import toast from 'react-hot-toast'
import {useBoolean, useEventTarget} from 'ahooks'
import {useEventChecked} from '@kky002/kky-hooks'
const Section = (props: {
title: ShowElement
htmlFor?: string
} & PropsWithChildren) => {
const {title, htmlFor, children} = props
return <div className='flex flex-col gap-1'>
<label className='font-medium desc-lighter text-xs' htmlFor={htmlFor}>{title}</label>
<div className='flex flex-col gap-1 rounded py-2 px-2 bg-base-200/75'>{children}</div>
</div>
}
const FormItem = (props: {
title: ShowElement
tip?: string
htmlFor?: string
} & PropsWithChildren) => {
const {title, tip, htmlFor, children} = props
return <div className='flex items-center gap-2'>
<div className={classNames('basis-3/12 flex-center', tip && 'tooltip tooltip-right z-[100] underline underline-offset-2 decoration-dashed')} data-tip={tip}>
<label className='font-medium desc' htmlFor={htmlFor}>{title}</label>
</div>
<div className='basis-9/12 flex items-center'>
{children}
</div>
</div>
}
const Settings = () => {
const dispatch = useAppDispatch()
const envData = useAppSelector(state => state.env.envData)
const {value: autoExpandValue, onChange: setAutoExpandValue} = useEventChecked(envData.autoExpand)
// const {value: autoScrollValue, onChange: setAutoScrollValue} = useEventChecked(envData.autoScroll)
const {value: translateEnableValue, onChange: setTranslateEnableValue} = useEventChecked(envData.translateEnable)
const {value: summarizeEnableValue, onChange: setSummarizeEnableValue} = useEventChecked(envData.summarizeEnable)
const {value: summarizeFloatValue, onChange: setSummarizeFloatValue} = useEventChecked(envData.summarizeFloat)
const [apiKeyValue, { onChange: onChangeApiKeyValue }] = useEventTarget({initialValue: envData.apiKey??''})
const [serverUrlValue, setServerUrlValue] = useState(envData.serverUrl)
const [languageValue, { onChange: onChangeLanguageValue }] = useEventTarget({initialValue: envData.language??LANGUAGE_DEFAULT})
const [summarizeLanguageValue, { onChange: onChangeSummarizeLanguageValue }] = useEventTarget({initialValue: envData.summarizeLanguage??SUMMARIZE_LANGUAGE_DEFAULT})
const [hideOnDisableAutoTranslateValue, setHideOnDisableAutoTranslateValue] = useState(envData.hideOnDisableAutoTranslate)
const [themeValue, setThemeValue] = useState(envData.theme)
const [fontSizeValue, setFontSizeValue] = useState(envData.fontSize)
const [transDisplayValue, setTransDisplayValue] = useState(envData.transDisplay)
const [wordsValue, setWordsValue] = useState(envData.words??WORDS_DEFAULT)
const [fetchAmountValue, setFetchAmountValue] = useState(envData.fetchAmount??TRANSLATE_FETCH_DEFAULT)
const [moreFold, {toggle: toggleMoreFold}] = useBoolean(true)
const fold = useAppSelector(state => state.env.fold)
const totalHeight = useAppSelector(state => state.env.totalHeight)
const wordsList = useMemo(() => {
const list = []
for (let i = WORDS_MIN; i <= WORDS_MAX; i += WORDS_STEP) {
list.push(i)
}
return list
}, [])
const transFetchAmountList = useMemo(() => {
const list = []
for (let i = TRANSLATE_FETCH_MIN; i <= TRANSLATE_FETCH_MAX; i += TRANSLATE_FETCH_STEP) {
list.push(i)
}
return list
}, [])
const onChangeHideOnDisableAutoTranslate = useCallback((e: any) => {
setHideOnDisableAutoTranslateValue(e.target.checked)
}, [])
const onSave = useCallback(() => {
dispatch(setEnvData({
autoExpand: autoExpandValue,
apiKey: apiKeyValue,
serverUrl: serverUrlValue,
translateEnable: translateEnableValue,
language: languageValue,
hideOnDisableAutoTranslate: hideOnDisableAutoTranslateValue,
theme: themeValue,
transDisplay: transDisplayValue,
summarizeEnable: summarizeEnableValue,
summarizeFloat: summarizeFloatValue,
summarizeLanguage: summarizeLanguageValue,
words: wordsValue,
fetchAmount: fetchAmountValue,
fontSize: fontSizeValue,
}))
dispatch(setPage(PAGE_MAIN))
toast.success('保存成功')
}, [fontSizeValue, apiKeyValue, autoExpandValue, dispatch, fetchAmountValue, hideOnDisableAutoTranslateValue, languageValue, serverUrlValue, summarizeEnableValue, summarizeFloatValue, summarizeLanguageValue, themeValue, transDisplayValue, translateEnableValue, wordsValue])
const onCancel = useCallback(() => {
dispatch(setPage(PAGE_MAIN))
}, [dispatch])
const onFetchAmountChange = useCallback((e: any) => {
setFetchAmountValue(parseInt(e.target.value))
}, [])
const onWordsChange = useCallback((e: any) => {
setWordsValue(parseInt(e.target.value))
}, [])
const onSel1 = useCallback(() => {
setTransDisplayValue('originPrimary')
}, [])
const onSel2 = useCallback(() => {
setTransDisplayValue('targetPrimary')
}, [])
const onSel3 = useCallback(() => {
setTransDisplayValue('target')
}, [])
const onSelTheme1 = useCallback(() => {
setThemeValue('system')
}, [])
const onSelTheme2 = useCallback(() => {
setThemeValue('light')
}, [])
const onSelTheme3 = useCallback(() => {
setThemeValue('dark')
}, [])
const onSelFontSize1 = useCallback(() => {
setFontSizeValue('normal')
}, [])
const onSelFontSize2 = useCallback(() => {
setFontSizeValue('large')
}, [])
return <div className='text-sm overflow-y-auto' style={{
height: fold?undefined:`${totalHeight-HEADER_HEIGHT}px`,
}}>
<div className="flex flex-col gap-3 p-2">
<Section title='通用配置'>
<FormItem title='自动展开' htmlFor='autoExpand' tip='是否视频有字幕时自动展开字幕列表'>
<input id='autoExpand' type='checkbox' className='toggle toggle-primary' checked={autoExpandValue}
onChange={setAutoExpandValue}/>
</FormItem>
<FormItem title='主题'>
<div className="btn-group">
<button onClick={onSelTheme1} className={classNames('btn btn-xs no-animation', (!themeValue || themeValue === 'system')?'btn-active':'')}></button>
<button onClick={onSelTheme2} className={classNames('btn btn-xs no-animation', themeValue === 'light'?'btn-active':'')}></button>
<button onClick={onSelTheme3} className={classNames('btn btn-xs no-animation', themeValue === 'dark'?'btn-active':'')}></button>
</div>
</FormItem>
<FormItem title='字体大小'>
<div className="btn-group">
<button onClick={onSelFontSize1} className={classNames('btn btn-xs no-animation', (!fontSizeValue || fontSizeValue === 'normal')?'btn-active':'')}></button>
<button onClick={onSelFontSize2} className={classNames('btn btn-xs no-animation', fontSizeValue === 'large'?'btn-active':'')}></button>
</div>
</FormItem>
</Section>
<Section title='openai配置'>
<FormItem title='ApiKey' htmlFor='apiKey'>
<input id='apiKey' type='text' className='input input-sm input-bordered w-full' placeholder='sk-xxx' value={apiKeyValue} onChange={onChangeApiKeyValue}/>
</FormItem>
<FormItem title='服务器' htmlFor='serverUrl'>
<input id='serverUrl' type='text' className='input input-sm input-bordered w-full' placeholder='服务器地址,默认使用官方地址' value={serverUrlValue} onChange={e => setServerUrlValue(e.target.value)}/>
</FormItem>
<div className='flex justify-center'>
<a className='link text-xs' onClick={toggleMoreFold}>{moreFold?'点击查看说明':'点击折叠说明'}</a>
</div>
{!moreFold && <div>
<ul className='pl-3 list-decimal desc text-xs'>
<li>访</li>
<li><a className='link' href='https://platform.openai.com/' target='_blank' rel="noreferrer">openai.com</a></li>
<li>(使ApiKey)<a className='link' onClick={() => setServerUrlValue(SERVER_URL_THIRD)} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://api2d.com/' target='_blank' rel="noreferrer">api2d</a> | <a className='link' onClick={() => setServerUrlValue('https://openai.api2d.net')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://openaimax.com/' target='_blank' rel="noreferrer">OpenAI-Max</a> | <a className='link' onClick={() => setServerUrlValue('https://api.openaimax.com')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://openai-sb.com/' target='_blank' rel="noreferrer">OpenAI-SB</a> | <a className='link' onClick={() => setServerUrlValue('https://api.openai-sb.com')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://www.ohmygpt.com/' target='_blank' rel="noreferrer">OhMyGPT</a> | <a className='link' onClick={() => setServerUrlValue('https://api.ohmygpt.com')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://aiproxy.io/' target='_blank' rel="noreferrer">AIProxy</a> | <a className='link' onClick={() => setServerUrlValue('https://api.aiproxy.io')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://key-rental.bowen.cool/' target='_blank' rel="noreferrer">Key Rental</a> | <a className='link' onClick={() => setServerUrlValue('https://key-rental-api.bowen.cool/openai')} rel='noreferrer'></a></li>
<li></li>
</ul>
</div>}
</Section>
<Section title={<div className='flex items-center'>
{!apiKeyValue && <div className='tooltip tooltip-right ml-1' data-tip='未设置ApiKey无法使用'>
<IoWarning className='text-sm text-warning'/>
</div>}
</div>}>
<FormItem title='启用翻译' htmlFor='translateEnable'>
<input id='translateEnable' type='checkbox' className='toggle toggle-primary' checked={translateEnableValue}
onChange={setTranslateEnableValue}/>
</FormItem>
<FormItem title='目标语言' htmlFor='language'>
<select id='language' className="select select-sm select-bordered" value={languageValue} onChange={onChangeLanguageValue}>
{LANGUAGES.map(language => <option key={language.code} value={language.code}>{language.name}</option>)}
</select>
</FormItem>
<FormItem title='翻译条数' tip='每次翻译条数'>
<div className='flex-1 flex flex-col'>
<input type="range" min={TRANSLATE_FETCH_MIN} max={TRANSLATE_FETCH_MAX} step={TRANSLATE_FETCH_STEP} value={fetchAmountValue} className="range range-primary" onChange={onFetchAmountChange} />
<div className="w-full flex justify-between text-xs px-2">
{transFetchAmountList.map(amount => <span key={amount}>{amount}</span>)}
</div>
</div>
</FormItem>
<FormItem title='翻译显示'>
<div className="btn-group">
<button onClick={onSel1} className={classNames('btn btn-xs no-animation', (!transDisplayValue || transDisplayValue === 'originPrimary')?'btn-active':'')}></button>
<button onClick={onSel2} className={classNames('btn btn-xs no-animation', transDisplayValue === 'targetPrimary'?'btn-active':'')}></button>
<button onClick={onSel3} className={classNames('btn btn-xs no-animation', transDisplayValue === 'target'?'btn-active':'')}></button>
</div>
</FormItem>
<FormItem title='隐藏翻译' tip='取消自动翻译时,隐藏已翻译内容' htmlFor='hideOnDisableAutoTranslate'>
<input id='hideOnDisableAutoTranslate' type='checkbox' className='toggle toggle-primary' checked={hideOnDisableAutoTranslateValue}
onChange={onChangeHideOnDisableAutoTranslate}/>
</FormItem>
</Section>
<Section title={<div className='flex items-center'>
{!apiKeyValue && <div className='tooltip tooltip-right ml-1' data-tip='未设置ApiKey无法使用'>
<IoWarning className='text-sm text-warning'/>
</div>}
</div>}>
<FormItem title='启用总结' htmlFor='summarizeEnable'>
<input id='summarizeEnable' type='checkbox' className='toggle toggle-primary' checked={summarizeEnableValue}
onChange={setSummarizeEnableValue}/>
</FormItem>
<FormItem title='浮动窗口' htmlFor='summarizeFloat' tip='当前总结离开视野时,是否显示浮动窗口'>
<input id='summarizeFloat' type='checkbox' className='toggle toggle-primary' checked={summarizeFloatValue}
onChange={setSummarizeFloatValue}/>
</FormItem>
<FormItem title='总结语言' htmlFor='summarizeLanguage'>
<select id='summarizeLanguage' className="select select-sm select-bordered" value={summarizeLanguageValue} onChange={onChangeSummarizeLanguageValue}>
{LANGUAGES.map(language => <option key={language.code} value={language.code}>{language.name}</option>)}
</select>
</FormItem>
<FormItem title='分段字数'>
<div className='flex-1 flex flex-col'>
<input type="range" min={WORDS_MIN} max={WORDS_MAX} step={WORDS_STEP} value={wordsValue} className="range range-primary" onChange={onWordsChange} />
<div className="w-full flex justify-between text-xs px-2">
{wordsList.map(words => <span key={words}>{words}</span>)}
</div>
</div>
</FormItem>
</Section>
<div className='flex justify-center gap-5'>
<button className='btn btn-primary btn-sm' onClick={onSave}></button>
<button className='btn btn-sm' onClick={onCancel}></button>
</div>
</div>
</div>
}
export default Settings