optimizations
This commit is contained in:
@@ -1,9 +1,195 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import EmojiPickerButton from '../comments/EmojiPickerButton'
|
||||
|
||||
function BoldIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ItalicIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} className="h-4 w-4">
|
||||
<line x1="19" y1="4" x2="10" y2="4" />
|
||||
<line x1="14" y1="20" x2="5" y2="20" />
|
||||
<line x1="15" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ListIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-4 w-4">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="18" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4">
|
||||
<path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.176 11 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179zM15.583 17.321C14.553 16.227 14 15 14 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C20.591 11.68 22 13.176 22 15c0 1.933-1.567 3.5-3.5 3.5-1.073 0-2.099-.456-2.917-1.179z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarBtn({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/[0.08] hover:text-white/70"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommentForm({ placeholder = 'Write a comment…', submitLabel = 'Post', onSubmit, onCancel, compact = false }) {
|
||||
const [content, setContent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const start = element.selectionStart
|
||||
const end = element.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = content.slice(0, start) + replacement + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cursorPos = selected ? start + replacement.length : start + before.length
|
||||
const cursorEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
element.selectionStart = cursorPos
|
||||
element.selectionEnd = cursorEnd
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const start = element.selectionStart
|
||||
const end = element.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const lines = selected ? selected.split('\n') : ['']
|
||||
const prefixed = lines.map((line) => prefix + line).join('\n')
|
||||
const next = content.slice(0, start) + prefixed + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.selectionStart = start
|
||||
element.selectionEnd = start + prefixed.length
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const start = element.selectionStart
|
||||
const end = element.selectionEnd
|
||||
const selected = content.slice(start, end)
|
||||
const isUrl = /^https?:\/\//.test(selected)
|
||||
const replacement = isUrl ? `[link](${selected})` : `[${selected || 'link'}](https://)`
|
||||
const next = content.slice(0, start) + replacement + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (isUrl) {
|
||||
element.selectionStart = start + 1
|
||||
element.selectionEnd = start + 5
|
||||
} else {
|
||||
const urlStart = start + replacement.length - 1
|
||||
element.selectionStart = urlStart - 8
|
||||
element.selectionEnd = urlStart - 1
|
||||
}
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const insertAtCursor = useCallback((text) => {
|
||||
const element = textareaRef.current
|
||||
if (!element) {
|
||||
setContent((current) => current + text)
|
||||
return
|
||||
}
|
||||
|
||||
const start = element.selectionStart ?? content.length
|
||||
const end = element.selectionEnd ?? content.length
|
||||
const next = content.slice(0, start) + text + content.slice(end)
|
||||
setContent(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
element.selectionStart = start + text.length
|
||||
element.selectionEnd = start + text.length
|
||||
element.focus()
|
||||
})
|
||||
}, [content])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const mod = event.ctrlKey || event.metaKey
|
||||
if (!mod) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -15,6 +201,7 @@ export default function CommentForm({ placeholder = 'Write a comment…', submit
|
||||
try {
|
||||
await onSubmit?.(trimmed)
|
||||
setContent('')
|
||||
setTab('write')
|
||||
} catch (submitError) {
|
||||
setError(submitError?.message || 'Unable to post comment.')
|
||||
} finally {
|
||||
@@ -24,19 +211,119 @@ export default function CommentForm({ placeholder = 'Write a comment…', submit
|
||||
|
||||
return (
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
rows={compact ? 3 : 4}
|
||||
maxLength={10000}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-2xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-white/35 outline-none transition focus:border-sky-400/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
<div className={`rounded-2xl border border-white/[0.08] bg-white/[0.04] transition-all duration-200 focus-within:border-white/[0.12] focus-within:shadow-lg focus-within:shadow-black/20 ${compact ? 'rounded-xl' : ''}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-3 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={[
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
tab === 'write' ? 'bg-white/[0.08] text-white' : 'text-white/40 hover:text-white/60',
|
||||
].join(' ')}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={[
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
tab === 'preview' ? 'bg-white/[0.08] text-white' : 'text-white/40 hover:text-white/60',
|
||||
].join(' ')}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={[
|
||||
'text-[11px] tabular-nums font-medium transition-colors',
|
||||
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
|
||||
].join(' ')}
|
||||
>
|
||||
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
|
||||
</span>
|
||||
<EmojiPickerButton onEmojiSelect={insertAtCursor} disabled={busy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<div className="flex items-center gap-0.5 border-b border-white/[0.04] px-3 py-1">
|
||||
<ToolbarBtn title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>
|
||||
<BoldIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>
|
||||
<ItalicIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>
|
||||
<CodeIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Link (Ctrl+K)" onClick={insertLink}>
|
||||
<LinkIcon />
|
||||
</ToolbarBtn>
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-white/[0.08]" />
|
||||
|
||||
<ToolbarBtn title="Bulleted list" onClick={() => prefixLines('- ')}>
|
||||
<ListIcon />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Quote" onClick={() => prefixLines('> ')}>
|
||||
<QuoteIcon />
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'write' && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={compact ? 3 : 4}
|
||||
maxLength={10000}
|
||||
placeholder={placeholder}
|
||||
disabled={busy}
|
||||
className="w-full resize-none bg-transparent px-4 py-3 text-sm leading-relaxed text-white/90 placeholder-white/35 outline-none transition disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[7rem] px-4 py-3">
|
||||
{content.trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-[13px] leading-relaxed text-white/80 [&_a]:text-accent [&_a]:no-underline hover:[&_a]:underline [&_code]:rounded [&_code]:bg-white/[0.08] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[12px] [&_code]:text-amber-300/80 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/40 [&_blockquote]:pl-3 [&_blockquote]:text-white/50 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:text-white/70 [&_strong]:text-white [&_em]:text-white/70 [&_p]:mb-2 [&_p:last-child]:mb-0">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/25">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'write' && (
|
||||
<div className="px-4 pb-2">
|
||||
<p className="text-[11px] text-white/15">
|
||||
Markdown supported · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+B</kbd> bold · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+I</kbd> italic · <kbd className="rounded bg-white/[0.06] px-1 py-0.5 text-[10px] text-white/25">Ctrl+K</kbd> link
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-white/35">{content.trim().length}/10000</span>
|
||||
<span className="text-xs text-white/35">Use emoji and markdown to match the rest of the site.</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel ? (
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import CommentForm from './CommentForm'
|
||||
|
||||
function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
function CommentItem({ comment, canReply, onReply, onDelete, onReport }) {
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -42,6 +42,11 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
{comment.can_report ? (
|
||||
<button type="button" onClick={() => onReport?.(comment)} className="transition hover:text-amber-300">
|
||||
Report
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{replying ? (
|
||||
@@ -62,7 +67,7 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
{Array.isArray(comment.replies) && comment.replies.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 border-l border-white/[0.08] pl-4">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} />
|
||||
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} onReport={onReport} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -72,7 +77,7 @@ function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, emptyMessage = 'No comments yet.' }) {
|
||||
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, onReport, emptyMessage = 'No comments yet.' }) {
|
||||
if (!comments.length) {
|
||||
return <p className="text-sm text-white/45">{emptyMessage}</p>
|
||||
}
|
||||
@@ -80,8 +85,8 @@ export default function CommentList({ comments = [], canReply = false, onReply,
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} />
|
||||
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} onReport={onReport} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function FollowButton({
|
||||
const [count, setCount] = useState(Number(initialCount || 0))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
@@ -65,6 +66,8 @@ export default function FollowButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
disabled={loading || !username}
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
className={[
|
||||
@@ -74,8 +77,8 @@ export default function FollowButton({
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? 'fa-user-check' : 'fa-user-plus'}`} />
|
||||
<span>{following ? 'Following' : 'Follow'}</span>
|
||||
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? (hovered ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus'}`} />
|
||||
<span>{following ? (hovered ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
||||
{showCount ? <span className="text-xs opacity-70">{count.toLocaleString()}</span> : null}
|
||||
</button>
|
||||
|
||||
@@ -94,4 +97,4 @@ export default function FollowButton({
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
40
resources/js/components/social/FollowersPreview.jsx
Normal file
40
resources/js/components/social/FollowersPreview.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function FollowersPreview({ users = [], label = '', href = null }) {
|
||||
if (!Array.isArray(users) || users.length === 0) return null
|
||||
|
||||
const preview = users.slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex -space-x-2">
|
||||
{preview.map((user) => (
|
||||
<a
|
||||
key={user.id}
|
||||
href={user.profile_url || `/@${user.username}`}
|
||||
className="inline-flex h-9 w-9 overflow-hidden rounded-full border border-[#09111f] bg-[#09111f] ring-1 ring-white/10"
|
||||
title={user.name || user.username}
|
||||
>
|
||||
<img
|
||||
src={user.avatar_url || '/images/avatar_default.webp'}
|
||||
alt={user.username || user.name}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white/90">{label}</p>
|
||||
{href ? (
|
||||
<a href={href} className="text-xs text-sky-300/80 transition-colors hover:text-sky-200">
|
||||
View network
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,14 +72,14 @@ export default function MessageInboxBadge({ initialUnreadCount = 0, userId = nul
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||
className="relative inline-flex h-9 w-9 lg:h-10 lg:w-10 items-center justify-center rounded-lg hover:bg-white/5"
|
||||
title="Messages"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="h-[18px] w-[18px] lg:h-5 lg:w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">
|
||||
<span className="absolute -bottom-1 right-0 rounded border border-sb-line bg-red-700/70 px-1 py-0 text-[10px] tabular-nums text-white lg:px-1.5 lg:py-0.5 lg:text-[11px]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
14
resources/js/components/social/MutualFollowersBadge.jsx
Normal file
14
resources/js/components/social/MutualFollowersBadge.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function MutualFollowersBadge({ context }) {
|
||||
const label = context?.follower_overlap?.label || context?.shared_following?.label || null
|
||||
|
||||
if (!label) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
|
||||
<i className="fa-solid fa-user-group fa-fw text-[11px]" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -96,15 +96,15 @@ export default function NotificationDropdown({ initialUnreadCount = 0, notificat
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
|
||||
className="relative inline-flex h-9 w-9 lg:h-10 lg:w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="h-[18px] w-[18px] lg:h-5 lg:w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
|
||||
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
||||
</svg>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -bottom-1 right-0 rounded bg-red-700/80 px-1.5 py-0.5 text-[11px] font-semibold text-white border border-sb-line">
|
||||
<span className="absolute -bottom-1 right-0 rounded border border-sb-line bg-red-700/80 px-1 py-0 text-[10px] font-semibold text-white lg:px-1.5 lg:py-0.5 lg:text-[11px]">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
101
resources/js/components/social/SuggestedUsersWidget.jsx
Normal file
101
resources/js/components/social/SuggestedUsersWidget.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import FollowButton from './FollowButton'
|
||||
|
||||
export default function SuggestedUsersWidget({
|
||||
title = 'Suggested Users',
|
||||
limit = 4,
|
||||
isLoggedIn = false,
|
||||
excludeUsername = null,
|
||||
initialUsers = null,
|
||||
}) {
|
||||
const [users, setUsers] = useState(Array.isArray(initialUsers) ? initialUsers : [])
|
||||
const [loading, setLoading] = useState(!Array.isArray(initialUsers) && isLoggedIn)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || Array.isArray(initialUsers)) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
axios.get('/api/users/suggestions', { params: { limit } })
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return
|
||||
const nextUsers = Array.isArray(data?.data) ? data.data : []
|
||||
setUsers(nextUsers)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUsers([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initialUsers, isLoggedIn, limit])
|
||||
|
||||
const visibleUsers = users
|
||||
.filter((user) => user?.username && user.username !== excludeUsername)
|
||||
.slice(0, limit)
|
||||
|
||||
if (!isLoggedIn) return null
|
||||
if (!loading && visibleUsers.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
<i className="fa-solid fa-compass text-slate-500 fa-fw text-[13px]" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{loading ? [1, 2, 3].map((key) => (
|
||||
<div key={key} className="animate-pulse flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-white/10" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-2.5 w-24 rounded bg-white/10" />
|
||||
<div className="h-2 w-32 rounded bg-white/6" />
|
||||
</div>
|
||||
</div>
|
||||
)) : visibleUsers.map((user) => (
|
||||
<div key={user.id} className="rounded-2xl border border-white/[0.05] bg-white/[0.03] p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<a href={user.profile_url || `/@${user.username}`} className="shrink-0">
|
||||
<img
|
||||
src={user.avatar_url || '/images/avatar_default.webp'}
|
||||
alt={user.username}
|
||||
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<a href={user.profile_url || `/@${user.username}`} className="block truncate text-sm font-semibold text-white/90 hover:text-white">
|
||||
{user.name || user.username}
|
||||
</a>
|
||||
<p className="truncate text-xs text-slate-500">@{user.username}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{user.context?.follower_overlap?.label || user.context?.shared_following?.label || user.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] text-slate-500">{Number(user.followers_count || 0).toLocaleString()} followers</span>
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={false}
|
||||
initialCount={Number(user.followers_count || 0)}
|
||||
showCount={false}
|
||||
sizeClassName="px-3 py-1.5 text-xs"
|
||||
className="min-w-[110px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user