Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { getGalleryFolders, getGalleryFiles, getSettings } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
import LoadMoreButton from '../components/LoadMoreButton'
|
||||
import HlsVideo from '../components/HlsVideo'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function formatShortDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'image', label: 'Images' },
|
||||
{ value: 'video', label: 'Videos' },
|
||||
]
|
||||
|
||||
export default function Gallery() {
|
||||
const [folders, setFolders] = useState([])
|
||||
const [files, setFiles] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const [activeFolder, setActiveFolder] = useState(null) // kept for API compat
|
||||
const [checkedFolders, setCheckedFolders] = useState(new Set())
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [shuffle, setShuffle] = useState(false)
|
||||
const [lightbox, setLightbox] = useState(null)
|
||||
const [slideshow, setSlideshow] = useState(false)
|
||||
const [hlsEnabled, setHlsEnabled] = useState(false)
|
||||
const [userFilterOpen, setUserFilterOpen] = useState(false)
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const filterRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
getSettings().then((data) => {
|
||||
if (!data.error) setHlsEnabled(data.hls_enabled === 'true')
|
||||
})
|
||||
getGalleryFolders().then((data) => {
|
||||
if (!data.error) setFolders(Array.isArray(data) ? data : [])
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Close popover on click outside
|
||||
useEffect(() => {
|
||||
if (!userFilterOpen) return
|
||||
const handleClick = (e) => {
|
||||
if (filterRef.current && !filterRef.current.contains(e.target)) {
|
||||
setUserFilterOpen(false)
|
||||
setUserSearch('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [userFilterOpen])
|
||||
|
||||
const togglePill = (name) => {
|
||||
setActiveFolder(null)
|
||||
setCheckedFolders((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setActiveFolder(null)
|
||||
setCheckedFolders(new Set())
|
||||
}
|
||||
|
||||
// Build the folder/folders param for the API
|
||||
// Checked folders take priority over active (clicked) folder
|
||||
const getFilterParams = useCallback(() => {
|
||||
if (checkedFolders.size > 0) {
|
||||
return { folders: Array.from(checkedFolders) }
|
||||
}
|
||||
if (activeFolder) {
|
||||
return { folder: activeFolder }
|
||||
}
|
||||
return {}
|
||||
}, [activeFolder, checkedFolders])
|
||||
|
||||
const loadFiles = useCallback(async (reset = true) => {
|
||||
if (reset) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
const offset = reset ? 0 : files.length
|
||||
const data = await getGalleryFiles({
|
||||
...getFilterParams(),
|
||||
type: typeFilter !== 'all' ? typeFilter : undefined,
|
||||
sort: shuffle ? 'shuffle' : 'latest',
|
||||
offset,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
setFiles((prev) => (reset ? data.files : [...prev, ...data.files]))
|
||||
setTotal(data.total)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}, [getFilterParams, typeFilter, shuffle, files.length])
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles(true)
|
||||
}, [activeFolder, checkedFolders, typeFilter, shuffle])
|
||||
|
||||
const handleReshuffle = () => {
|
||||
loadFiles(true)
|
||||
}
|
||||
|
||||
const hasMore = files.length < total
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Gallery</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{total} file{total !== 1 ? 's' : ''}{checkedFolders.size === 0 ? ' saved locally' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||
{/* User Filter Popover */}
|
||||
<div className="relative" ref={filterRef}>
|
||||
<button
|
||||
onClick={() => { setUserFilterOpen((v) => !v); setUserSearch('') }}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
checkedFolders.size > 0
|
||||
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
|
||||
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<UsersFilterIcon className="w-4 h-4" />
|
||||
Users
|
||||
{checkedFolders.size > 0 && (
|
||||
<span className="bg-[#0095f6] text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-medium">
|
||||
{checkedFolders.size}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{userFilterOpen && (
|
||||
<div className="absolute top-full left-0 mt-2 w-64 bg-[#1a1a1a] border border-[#333] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-[#333]">
|
||||
<input
|
||||
type="text"
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
autoFocus
|
||||
className="w-full px-3 py-1.5 bg-[#111] border border-[#333] rounded-md text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#0095f6]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{folders
|
||||
.filter((f) => f.name.toLowerCase().includes(userSearch.toLowerCase()))
|
||||
.map((f) => (
|
||||
<button
|
||||
key={f.name}
|
||||
onClick={() => togglePill(f.name)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-[#252525] transition-colors text-left"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex-shrink-0 flex items-center justify-center ${
|
||||
checkedFolders.has(f.name)
|
||||
? 'bg-[#0095f6] border-[#0095f6]'
|
||||
: 'border-[#555]'
|
||||
}`}>
|
||||
{checkedFolders.has(f.name) && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-300 truncate flex-1">{f.name}</span>
|
||||
<span className="text-xs text-gray-600 flex-shrink-0">{f.total}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{checkedFolders.size > 0 && (
|
||||
<div className="p-2 border-t border-[#333]">
|
||||
<button
|
||||
onClick={() => { clearFilters(); setUserFilterOpen(false) }}
|
||||
className="w-full py-1.5 text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected user tags */}
|
||||
{checkedFolders.size > 0 && checkedFolders.size <= 5 && (
|
||||
Array.from(checkedFolders).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs bg-[#0095f6]/10 text-[#0095f6] rounded-lg border border-[#0095f6]/30"
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
onClick={() => togglePill(name)}
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex rounded-lg overflow-hidden border border-[#333]">
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTypeFilter(opt.value)}
|
||||
className={`px-3 py-2 text-sm transition-colors ${
|
||||
typeFilter === opt.value
|
||||
? 'bg-[#0095f6] text-white'
|
||||
: 'bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shuffle Toggle */}
|
||||
<button
|
||||
onClick={() => setShuffle((s) => !s)}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
shuffle
|
||||
? 'border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]'
|
||||
: 'border-[#333] bg-[#161616] text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<ShuffleIcon className="w-4 h-4" />
|
||||
Shuffle
|
||||
</button>
|
||||
|
||||
{/* Reshuffle Button */}
|
||||
{shuffle && (
|
||||
<button
|
||||
onClick={handleReshuffle}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshIcon className="w-4 h-4" />
|
||||
Reshuffle
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Slideshow Button */}
|
||||
<button
|
||||
onClick={() => setSlideshow(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[#333] bg-[#161616] text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<SlideshowIcon className="w-4 h-4" />
|
||||
Slideshow
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-16 bg-[#161616] border border-[#222] rounded-lg">
|
||||
<GalleryIcon className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">No media files found</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
Download media from the Users or Search page
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={`${file.folder}-${file.filename}-${i}`}
|
||||
className="relative group bg-[#161616] rounded-lg overflow-hidden cursor-pointer aspect-square"
|
||||
onClick={() => setLightbox(file)}
|
||||
>
|
||||
{file.type === 'video' ? (
|
||||
<video
|
||||
src={`${file.url}#t=0.5`}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={file.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date badge */}
|
||||
{file.postedAt && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className="bg-black/50 text-white/80 text-[10px] px-1.5 py-0.5 rounded">
|
||||
{formatShortDate(file.postedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end">
|
||||
<div className="w-full p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-xs text-white truncate">@{file.folder}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video badge */}
|
||||
{file.type === 'video' && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<svg className="w-5 h-5 text-white drop-shadow-lg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LoadMoreButton
|
||||
onClick={() => loadFiles(false)}
|
||||
loading={loadingMore}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<Lightbox file={lightbox} hlsEnabled={hlsEnabled} onClose={() => setLightbox(null)} />
|
||||
)}
|
||||
|
||||
{/* Slideshow */}
|
||||
{slideshow && (
|
||||
<Slideshow
|
||||
filterParams={getFilterParams()}
|
||||
onClose={() => setSlideshow(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Lightbox({ file, hlsEnabled, onClose }) {
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white z-10"
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
|
||||
{file.type === 'video' ? (
|
||||
<HlsVideo
|
||||
hlsSrc={hlsEnabled && file.type === 'video' ? `/api/hls/${encodeURIComponent(file.folder)}/${encodeURIComponent(file.filename)}/master.m3u8` : null}
|
||||
src={file.url}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-w-full max-h-[90vh] rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={file.url}
|
||||
alt=""
|
||||
className="max-w-full max-h-[90vh] rounded-lg object-contain"
|
||||
/>
|
||||
)}
|
||||
<p className="text-center text-sm text-gray-400 mt-3">@{file.folder}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Slideshow({ filterParams, onClose }) {
|
||||
const [current, setCurrent] = useState(null)
|
||||
const [images, setImages] = useState([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
// Load a large shuffled batch of images
|
||||
useEffect(() => {
|
||||
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
|
||||
if (!data.error && data.files.length > 0) {
|
||||
setImages(data.files)
|
||||
setCurrent(data.files[0])
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auto-advance every 5 seconds
|
||||
useEffect(() => {
|
||||
if (images.length === 0 || paused) return
|
||||
const timer = setInterval(() => {
|
||||
setIndex((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= images.length) {
|
||||
getGalleryFiles({ ...filterParams, type: 'image', sort: 'shuffle', limit: 500 }).then((data) => {
|
||||
if (!data.error && data.files.length > 0) {
|
||||
setImages(data.files)
|
||||
setCurrent(data.files[0])
|
||||
setIndex(0)
|
||||
}
|
||||
})
|
||||
return prev
|
||||
}
|
||||
setCurrent(images[next])
|
||||
return next
|
||||
})
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [images, paused])
|
||||
|
||||
// Keyboard controls
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p) }
|
||||
if (e.key === 'ArrowRight' && images.length > 0) {
|
||||
setIndex((prev) => {
|
||||
const next = Math.min(prev + 1, images.length - 1)
|
||||
setCurrent(images[next])
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && images.length > 0) {
|
||||
setIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0)
|
||||
setCurrent(images[next])
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [onClose, images])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-black flex items-center justify-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/50 hover:text-white z-10"
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{paused && (
|
||||
<div className="absolute top-4 left-4 text-white/50 text-sm z-10">
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current ? (
|
||||
<img
|
||||
key={current.url}
|
||||
src={current.url}
|
||||
alt=""
|
||||
className="max-w-full max-h-full object-contain animate-fadeIn"
|
||||
/>
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
|
||||
{current && (
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center text-white/40 text-sm">
|
||||
@{current.folder} · {index + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlideshowIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ShuffleIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RefreshIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function UsersFilterIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GalleryIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M18 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user