返回 oh-my-ppt
AssetPickerDialog.tsx
根目录 / src / renderer / src / components / session-detail / modal / AssetPickerDialog.tsx
1 import { useEffect, useRef, useState } from 'react'
2 import { Play } from 'lucide-react'
3 import { cn } from '@renderer/lib/utils'
4 import { ipc } from '@renderer/lib/ipc'
5 import {
6 Dialog,
7 DialogContent,
8 DialogFooter,
9 DialogHeader,
10 DialogTitle,
11 DialogDescription
12 } from '../../ui/Dialog'
13 import { Button } from '../../ui/Button'
14 import { useT } from '@renderer/i18n'
15 import { localAssetUrl } from '@shared/local-asset'
16
17 interface AssetEntry {
18 fileName: string
19 relativePath: string
20 absolutePath: string
21 }
22
23 function CheckIcon({ checked }: { checked: boolean }): React.JSX.Element {
24 return (
25 <div
26 className={cn(
27 'flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all duration-200',
28 checked
29 ? 'border-[#6f8159] bg-[#6f8159] text-white'
30 : 'border-[#ded2bd]/80 bg-white/70 text-transparent group-hover:border-[#b5c9a0]'
31 )}
32 >
33 <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
34 <path
35 d="M2.5 6L5 8.5L9.5 3.5"
36 stroke="currentColor"
37 strokeWidth="1.8"
38 strokeLinecap="round"
39 strokeLinejoin="round"
40 />
41 </svg>
42 </div>
43 )
44 }
45
46 export function AssetPickerDialog({
47 sessionId,
48 assetType,
49 open,
50 onClose,
51 onConfirm
52 }: {
53 sessionId: string
54 assetType: 'image' | 'video'
55 open: boolean
56 onClose: () => void
57 onConfirm: (relativePath: string, fileName: string) => void
58 }): React.JSX.Element {
59 const t = useT()
60 const [assets, setAssets] = useState<AssetEntry[]>([])
61 const [selected, setSelected] = useState<string | null>(null)
62 const [loading, setLoading] = useState(false)
63 const [playingPath, setPlayingPath] = useState<string | null>(null)
64 const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map())
65
66 useEffect(() => {
67 if (!open) {
68 setSelected(null)
69 setPlayingPath(null)
70 videoRefs.current.clear()
71 return
72 }
73 setLoading(true)
74 ipc
75 .listAssets(sessionId, assetType)
76 .then((result) => setAssets(result.assets))
77 .catch(() => setAssets([]))
78 .finally(() => setLoading(false))
79 }, [open, sessionId, assetType])
80
81 const handleConfirm = (): void => {
82 if (!selected) return
83 const asset = assets.find((a) => a.relativePath === selected)
84 if (!asset) return
85 onConfirm(asset.relativePath, asset.fileName)
86 onClose()
87 }
88
89 const title = assetType === 'image' ? t('editMode.chooseImage') : t('editMode.chooseVideo')
90 const isVideo = assetType === 'video'
91
92 return (
93 <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
94 <DialogContent className="max-w-lg">
95 <DialogHeader>
96 <DialogTitle>{title}</DialogTitle>
97 <DialogDescription>{t('editMode.assetPickerHint')}</DialogDescription>
98 </DialogHeader>
99
100 {loading ? (
101 <div className="flex h-48 items-center justify-center text-sm text-[#6f6658]">
102 {t('editMode.loadingAssets')}
103 </div>
104 ) : assets.length === 0 ? (
105 <div className="flex h-48 items-center justify-center text-sm text-[#6f6658]">
106 {t('editMode.noAssets')}
107 </div>
108 ) : (
109 <div className="grid max-h-[340px] grid-cols-3 gap-2 overflow-y-auto p-1">
110 {assets.map((asset) => {
111 const checked = selected === asset.relativePath
112 return (
113 <div
114 key={asset.relativePath}
115 className={cn(
116 'group overflow-hidden rounded-lg border-2 transition-all duration-200',
117 checked
118 ? 'border-[#6f8159] ring-2 ring-[#6f8159]/40 shadow-md shadow-[#6f8159]/20'
119 : 'border-[#ded2bd]/60 hover:border-[#b5c9a0] hover:shadow-md hover:shadow-[#c7d9b4]/40 active:scale-[0.97]'
120 )}
121 >
122 <div className="relative aspect-[4/3]">
123 {isVideo ? (
124 playingPath === asset.relativePath ? (
125 <video
126 ref={(el) => {
127 if (el) videoRefs.current.set(asset.relativePath, el)
128 }}
129 src={localAssetUrl(asset.absolutePath)}
130 controls
131 autoPlay
132 playsInline
133 className="h-full w-full bg-black"
134 />
135 ) : (
136 <>
137 <video
138 src={localAssetUrl(asset.absolutePath)}
139 preload="metadata"
140 muted
141 playsInline
142 className="h-full w-full object-cover bg-black"
143 />
144 <button
145 type="button"
146 onClick={() => setPlayingPath(asset.relativePath)}
147 className="absolute inset-0 flex items-center justify-center bg-black/15 transition-opacity hover:bg-black/25"
148 >
149 <div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/80 shadow backdrop-blur-sm">
150 <Play className="h-4 w-4 translate-x-[1px] text-[#3e4a32]" />
151 </div>
152 </button>
153 </>
154 )
155 ) : (
156 <img
157 src={localAssetUrl(asset.absolutePath)}
158 alt={asset.fileName}
159 className={cn(
160 'h-full w-full object-cover transition-transform duration-200',
161 !checked && 'group-hover:scale-105'
162 )}
163 />
164 )}
165 <button
166 type="button"
167 onClick={() => setSelected(checked ? null : asset.relativePath)}
168 className="absolute right-1.5 top-1.5 z-10 cursor-pointer"
169 >
170 <CheckIcon checked={checked} />
171 </button>
172 </div>
173 <div className="bg-[#faf6ef] px-1.5 py-1 text-[10px] text-[#6f6658] truncate">
174 {asset.fileName}
175 </div>
176 </div>
177 )
178 })}
179 </div>
180 )}
181
182 <DialogFooter>
183 <Button variant="ghost" size="sm" onClick={onClose}>
184 {t('editMode.cancel')}
185 </Button>
186 <Button size="sm" disabled={!selected} onClick={handleConfirm}>
187 {t('editMode.confirmAdd')}
188 </Button>
189 </DialogFooter>
190 </DialogContent>
191 </Dialog>
192 )
193 }
194
194 lines Plain Text