add ratings to doom scroll
This commit is contained in:
@@ -41,14 +41,29 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
|
||||
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
|
||||
|
||||
// Tools overlay visibility
|
||||
const [showToolsOverlay, setShowToolsOverlay] = useState(false)
|
||||
|
||||
// Rating state
|
||||
const [userRating, setUserRatingState] = useState<number | null>(null)
|
||||
const [ratingHover, setRatingHover] = useState<number | null>(null)
|
||||
const [savingRating, setSavingRating] = useState(false)
|
||||
|
||||
// Text overlay state
|
||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||
const [savingText, setSavingText] = useState(false)
|
||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [extractError, setExtractError] = useState<string | null>(null)
|
||||
const [extractPending, setExtractPending] = useState(false)
|
||||
const [retranslating, setRetranslating] = useState(false)
|
||||
const [translatePending, setTranslatePending] = useState(false)
|
||||
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
|
||||
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
|
||||
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -128,27 +143,50 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
return () => clearTimeout(id)
|
||||
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
|
||||
|
||||
// Fetch extracted text for current item; clear any in-flight poll on item change
|
||||
// Fetch OCR settings once on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/ai-settings/ocr')
|
||||
.then((r) => r.json())
|
||||
.then((d: { ocrMode: string; ocrLanguages: string }) => {
|
||||
setDefaultOcrLanguages(d.ocrLanguages)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Fetch extracted text + rating for current item; clear any in-flight poll on item change
|
||||
useEffect(() => {
|
||||
if (extractPollRef.current) {
|
||||
clearInterval(extractPollRef.current)
|
||||
extractPollRef.current = null
|
||||
}
|
||||
setExtractedText(null)
|
||||
setEditedExtractedText('')
|
||||
setTranslatedText(null)
|
||||
setShowTextOverlay(false)
|
||||
setShowOriginal(false)
|
||||
setExtracting(false)
|
||||
setExtractError(null)
|
||||
setExtractPending(false)
|
||||
setRetranslating(false)
|
||||
setTranslatePending(false)
|
||||
setUserRatingState(null)
|
||||
setRatingHover(null)
|
||||
if (!current?.itemKey) return
|
||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
|
||||
const key = current.itemKey
|
||||
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||
setExtractedText(data.extractedText)
|
||||
setEditedExtractedText(data.extractedText ?? '')
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
})
|
||||
.catch(() => {})
|
||||
fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { userRating: number | null }) => {
|
||||
setUserRatingState(data.userRating)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [current?.itemKey])
|
||||
|
||||
// Clean up poll on unmount
|
||||
@@ -196,7 +234,58 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
}
|
||||
}, [navigate, onClose, extractedText])
|
||||
|
||||
const handleExtractText = async () => {
|
||||
// ── Polling helper ──────────────────────────────────────────────────────────
|
||||
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => {
|
||||
if (!current?.itemKey) return
|
||||
const itemKey = current.itemKey
|
||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||
const deadline = Date.now() + 5 * 60 * 1000
|
||||
extractPollRef.current = setInterval(async () => {
|
||||
if (Date.now() > deadline) {
|
||||
clearInterval(extractPollRef.current!)
|
||||
extractPollRef.current = null
|
||||
setExtractPending(false)
|
||||
setTranslatePending(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
|
||||
const textChanged = data.extractedText !== snapshotText
|
||||
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
|
||||
if (textChanged || translationChanged) {
|
||||
clearInterval(extractPollRef.current!)
|
||||
extractPollRef.current = null
|
||||
setExtractedText(data.extractedText)
|
||||
setEditedExtractedText(data.extractedText ?? '')
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
setExtractPending(false)
|
||||
setTranslatePending(false)
|
||||
if (data.extractedText) setShowTextOverlay(true)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000)
|
||||
}, [current?.itemKey])
|
||||
|
||||
// ── Rating actions ───────────────────────────────────────────────────────────
|
||||
const handleSetRating = useCallback(async (star: number) => {
|
||||
if (!current?.itemKey) return
|
||||
const next = userRating === star ? null : star
|
||||
setSavingRating(true)
|
||||
try {
|
||||
const res = await fetch('/api/ratings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey: current.itemKey, userRating: next }),
|
||||
})
|
||||
if (res.ok) setUserRatingState(next)
|
||||
} finally {
|
||||
setSavingRating(false)
|
||||
}
|
||||
}, [current?.itemKey, userRating])
|
||||
|
||||
// ── Text extraction ──────────────────────────────────────────────────────────
|
||||
const callExtract = useCallback(async (modeOverride: string) => {
|
||||
if (!current?.itemKey) return
|
||||
const itemKey = current.itemKey
|
||||
setExtracting(true)
|
||||
@@ -206,30 +295,15 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
const res = await fetch('/api/ai-tagging/extract-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey }),
|
||||
body: JSON.stringify({
|
||||
itemKey,
|
||||
ocrMode: modeOverride,
|
||||
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
|
||||
}),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
// Job queued — poll until it completes (up to 5 min)
|
||||
setExtractPending(true)
|
||||
const deadline = Date.now() + 5 * 60 * 1000
|
||||
extractPollRef.current = setInterval(async () => {
|
||||
if (Date.now() > deadline) {
|
||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||
setExtractPending(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
|
||||
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
|
||||
if (data.extractedText) {
|
||||
if (extractPollRef.current) clearInterval(extractPollRef.current)
|
||||
setExtractPending(false)
|
||||
setExtractedText(data.extractedText)
|
||||
setTranslatedText(data.extractedTextTranslated)
|
||||
setShowTextOverlay(true)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000)
|
||||
startPolling(extractedText, translatedText)
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
@@ -237,16 +311,67 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||
}
|
||||
const result = await res.json()
|
||||
setExtractedText(result.extractedText || null)
|
||||
setTranslatedText(result.translatedText || null)
|
||||
if (result.extractedText) setShowTextOverlay(true)
|
||||
const newText: string | null = result.extractedText || null
|
||||
const newTranslated: string | null = result.translatedText || null
|
||||
setExtractedText(newText)
|
||||
setEditedExtractedText(newText ?? '')
|
||||
setTranslatedText(newTranslated)
|
||||
if (newText) setShowTextOverlay(true)
|
||||
} catch (err) {
|
||||
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
|
||||
setTimeout(() => setExtractError(null), 4000)
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}
|
||||
}, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling])
|
||||
|
||||
// ── Save edited extracted text ───────────────────────────────────────────────
|
||||
const handleSaveExtractedText = useCallback(async () => {
|
||||
if (!current?.itemKey) return
|
||||
setSavingText(true)
|
||||
try {
|
||||
await fetch('/api/ai-tagging/fields', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }),
|
||||
})
|
||||
setExtractedText(editedExtractedText)
|
||||
} finally {
|
||||
setSavingText(false)
|
||||
}
|
||||
}, [current?.itemKey, editedExtractedText])
|
||||
|
||||
// ── Translation ──────────────────────────────────────────────────────────────
|
||||
const handleTranslate = useCallback(async () => {
|
||||
if (!current?.itemKey) return
|
||||
setRetranslating(true)
|
||||
setTranslatePending(false)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
itemKey: current.itemKey,
|
||||
...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }),
|
||||
}),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
setTranslatePending(true)
|
||||
startPolling(extractedText, translatedText)
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Translation failed')
|
||||
}
|
||||
const result = await res.json()
|
||||
setTranslatedText(result.translatedText || null)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRetranslating(false)
|
||||
}
|
||||
}, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
|
||||
@@ -333,6 +458,193 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tools overlay — anchored lower-left, above the bottom bar */}
|
||||
{showToolsOverlay && current?.itemKey && (
|
||||
<div
|
||||
className="absolute bottom-16 left-4 z-20 rounded-xl p-4 flex flex-col gap-3 overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: 'rgba(10,10,10,0.92)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
width: 'min(320px, calc(100vw - 2rem))',
|
||||
maxHeight: 'calc(100vh - 8rem)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Rating ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
||||
Rating
|
||||
</p>
|
||||
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = (ratingHover ?? userRating ?? 0) >= star
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleSetRating(star)}
|
||||
onMouseEnter={() => setRatingHover(star)}
|
||||
disabled={savingRating}
|
||||
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
|
||||
style={{
|
||||
fontSize: '1.4rem',
|
||||
color: filled ? '#f59e0b' : 'rgba(255,255,255,0.2)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '0 2px',
|
||||
cursor: savingRating ? 'wait' : 'pointer',
|
||||
transition: 'color 0.1s',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Text Extraction (images only) ───────────────────── */}
|
||||
{current.mediaType === 'image' && (
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '0.75rem' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'rgba(255,255,255,0.45)' }}>
|
||||
Text Extraction
|
||||
</p>
|
||||
<button
|
||||
onClick={() => callExtract('llm')}
|
||||
disabled={extracting || extractPending}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center transition-opacity disabled:opacity-40"
|
||||
style={{
|
||||
backgroundColor: extractPending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
||||
color: extractPending ? '#fff' : 'rgba(255,255,255,0.7)',
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
aria-label="Extract with AI"
|
||||
title="Extract with AI (skips OCR)"
|
||||
>
|
||||
{extracting || extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => callExtract('tesseract')}
|
||||
disabled={extracting || extractPending}
|
||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-40 flex-shrink-0"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={ocrLanguageInput}
|
||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||
placeholder={defaultOcrLanguages}
|
||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
width: 120,
|
||||
}}
|
||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{extractError && (
|
||||
<p className="text-xs mt-1" style={{ color: '#f87171' }}>{extractError}</p>
|
||||
)}
|
||||
|
||||
{/* Extracted text editor */}
|
||||
{extractedText !== null && (
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<p className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.45)' }}>Extracted Text</p>
|
||||
<textarea
|
||||
value={editedExtractedText}
|
||||
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
minHeight: '3.5rem',
|
||||
maxHeight: '8rem',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{editedExtractedText !== extractedText && (
|
||||
<button
|
||||
onClick={handleSaveExtractedText}
|
||||
disabled={savingText}
|
||||
className="self-start text-xs px-2 py-0.5 rounded-full transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{savingText ? '⟳ Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Translation display */}
|
||||
{translatedText && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs font-medium mb-1" style={{ color: 'rgba(255,255,255,0.45)' }}>Translation</p>
|
||||
<pre
|
||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-32 overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
}}
|
||||
>
|
||||
{translatedText}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Original / translation toggle */}
|
||||
{extractedText && translatedText && (
|
||||
<button
|
||||
onClick={() => setShowOriginal((v) => !v)}
|
||||
className="self-start text-xs px-2 py-0.5 rounded-full mt-1"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
{showOriginal ? 'Show Translation in popover' : 'Show Original in popover'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Translate / re-translate */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={sourceLanguage}
|
||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||
placeholder="Source lang…"
|
||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
width: 100,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleTranslate}
|
||||
disabled={retranslating || translatePending}
|
||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-40"
|
||||
style={{
|
||||
backgroundColor: translatePending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
|
||||
color: translatePending ? '#fff' : 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text overlay */}
|
||||
{showTextOverlay && displayText && (
|
||||
<div
|
||||
@@ -357,9 +669,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar: mute | filename | action buttons */}
|
||||
{/* Bottom bar: [mute + tools] | filename | action buttons */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
|
||||
<div className="w-9 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isVideo && (
|
||||
<button
|
||||
onClick={() => setLocalMuted((v) => !v)}
|
||||
@@ -382,6 +694,27 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{current?.itemKey && (
|
||||
<button
|
||||
onClick={() => setShowToolsOverlay((v) => !v)}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
|
||||
style={{
|
||||
backgroundColor: showToolsOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
}}
|
||||
aria-label={showToolsOverlay ? 'Close tools' : 'Open tools'}
|
||||
title="Rating & text tools"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
<line x1="12" y1="2" x2="12" y2="5"/>
|
||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
||||
<line x1="2" y1="12" x2="5" y2="12"/>
|
||||
<line x1="19" y1="12" x2="22" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||||
{current?.name}
|
||||
@@ -405,7 +738,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
||||
</button>
|
||||
) : current?.itemKey && current?.mediaType === 'image' ? (
|
||||
<button
|
||||
onClick={handleExtractText}
|
||||
onClick={() => callExtract('tesseract')}
|
||||
disabled={extracting || extractPending}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user