feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
This commit is contained in:
54
resources/js/components/Feed/EmbeddedArtworkCard.jsx
Normal file
54
resources/js/components/Feed/EmbeddedArtworkCard.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Compact artwork card for embedding inside a PostCard.
|
||||
* Shows thumbnail, title and original author with attribution.
|
||||
*/
|
||||
export default function EmbeddedArtworkCard({ artwork }) {
|
||||
if (!artwork) return null
|
||||
|
||||
const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}`
|
||||
const authorUrl = `/@${artwork.author.username}`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={artUrl}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors"
|
||||
title={artwork.title}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-16 rounded-lg overflow-hidden shrink-0 bg-white/5">
|
||||
{artwork.thumb_url ? (
|
||||
<img
|
||||
src={artwork.thumb_url}
|
||||
alt={artwork.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-col justify-center min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">{artwork.title}</p>
|
||||
<a
|
||||
href={authorUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-slate-400 hover:text-sky-400 transition-colors mt-0.5 truncate"
|
||||
>
|
||||
<i className="fa-solid fa-user-circle fa-fw mr-1 opacity-60" />
|
||||
by {artwork.author.name || `@${artwork.author.username}`}
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
396
resources/js/components/Feed/FeedSidebar.jsx
Normal file
396
resources/js/components/Feed/FeedSidebar.jsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(n) {
|
||||
if (n === null || n === undefined) return '0'
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const SOCIAL_META = {
|
||||
twitter: { icon: 'fa-brands fa-x-twitter', label: 'Twitter / X', prefix: 'https://x.com/' },
|
||||
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram', prefix: 'https://instagram.com/' },
|
||||
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt', prefix: 'https://deviantart.com/' },
|
||||
artstation: { icon: 'fa-brands fa-artstation', label: 'ArtStation', prefix: 'https://artstation.com/' },
|
||||
behance: { icon: 'fa-brands fa-behance', label: 'Behance', prefix: 'https://behance.net/' },
|
||||
website: { icon: 'fa-solid fa-globe', label: 'Website', prefix: '' },
|
||||
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube', prefix: '' },
|
||||
twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch', prefix: '' },
|
||||
}
|
||||
|
||||
function SideCard({ title, icon, children, className = '' }) {
|
||||
return (
|
||||
<div className={`rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden ${className}`}>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
{icon && <i className={`${icon} text-slate-500 fa-fw text-[13px]`} />}
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Stats card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsCard({ stats, followerCount, user, onTabChange }) {
|
||||
const items = [
|
||||
{
|
||||
label: 'Artworks',
|
||||
value: fmt(stats?.uploads_count ?? 0),
|
||||
icon: 'fa-solid fa-image',
|
||||
color: 'text-sky-400',
|
||||
tab: 'artworks',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: fmt(followerCount ?? stats?.followers_count ?? 0),
|
||||
icon: 'fa-solid fa-user-group',
|
||||
color: 'text-violet-400',
|
||||
tab: null,
|
||||
},
|
||||
{
|
||||
label: 'Following',
|
||||
value: fmt(stats?.following_count ?? 0),
|
||||
icon: 'fa-solid fa-user-plus',
|
||||
color: 'text-emerald-400',
|
||||
tab: null,
|
||||
},
|
||||
{
|
||||
label: 'Awards',
|
||||
value: fmt(stats?.awards_received_count ?? 0),
|
||||
icon: 'fa-solid fa-trophy',
|
||||
color: 'text-amber-400',
|
||||
tab: 'stats',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SideCard title="Stats" icon="fa-solid fa-chart-simple">
|
||||
<div className="grid grid-cols-2 divide-x divide-y divide-white/[0.05]">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => item.tab && onTabChange?.(item.tab)}
|
||||
className={`flex flex-col items-center gap-1 py-4 px-3 transition-colors group ${
|
||||
item.tab ? 'hover:bg-white/[0.04] cursor-pointer' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
<i className={`${item.icon} ${item.color} text-sm fa-fw mb-0.5 group-hover:scale-110 transition-transform`} />
|
||||
<span className="text-xl font-bold text-white/90 tabular-nums leading-none">{item.value}</span>
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-wide">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// About card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function AboutCard({ user, profile, socialLinks, countryName }) {
|
||||
const bio = profile?.bio || profile?.about || profile?.description
|
||||
const website = profile?.website || user?.website
|
||||
|
||||
const hasSocials = socialLinks && Object.keys(socialLinks).length > 0
|
||||
const hasContent = bio || countryName || website || hasSocials
|
||||
|
||||
if (!hasContent) return null
|
||||
|
||||
return (
|
||||
<SideCard title="About" icon="fa-solid fa-circle-info">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{bio && (
|
||||
<p className="text-sm text-slate-300 leading-relaxed line-clamp-4">{bio}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{countryName && (
|
||||
<div className="flex items-center gap-2 text-[13px] text-slate-400">
|
||||
<i className="fa-solid fa-location-dot fa-fw text-slate-600 text-xs" />
|
||||
<span>{countryName}</span>
|
||||
</div>
|
||||
)}
|
||||
{website && (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<i className="fa-solid fa-link fa-fw text-slate-600 text-xs" />
|
||||
<a
|
||||
href={website.startsWith('http') ? website : `https://${website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="text-sky-400/80 hover:text-sky-400 transition-colors truncate max-w-[200px]"
|
||||
>
|
||||
{website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSocials && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{Object.entries(socialLinks).map(([platform, link]) => {
|
||||
const meta = SOCIAL_META[platform] ?? SOCIAL_META.website
|
||||
const url = link.url || (meta.prefix ? meta.prefix + link.handle : null)
|
||||
if (!url) return null
|
||||
return (
|
||||
<a
|
||||
key={platform}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
title={meta.label}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 hover:bg-sky-500/15 text-slate-400 hover:text-sky-400 transition-all border border-white/[0.06] hover:border-sky-500/30"
|
||||
>
|
||||
<i className={`${meta.icon} text-sm`} />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recent followers card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function RecentFollowersCard({ recentFollowers, followerCount, onTabChange }) {
|
||||
const followers = recentFollowers ?? []
|
||||
if (followers.length === 0) return null
|
||||
|
||||
return (
|
||||
<SideCard title="Recent Followers" icon="fa-solid fa-user-group">
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
{followers.slice(0, 6).map((f) => (
|
||||
<a
|
||||
key={f.id}
|
||||
href={f.profile_url ?? `/@${f.username}`}
|
||||
className="flex items-center gap-2.5 group"
|
||||
>
|
||||
<img
|
||||
src={f.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={f.username}
|
||||
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
|
||||
{f.name || f.uname || f.username}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-600 truncate">@{f.username}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{followerCount > 6 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange?.('artworks')}
|
||||
className="w-full text-center text-[12px] text-slate-500 hover:text-sky-400 transition-colors pt-1"
|
||||
>
|
||||
View all {fmt(followerCount)} followers →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Trending hashtags card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrendingHashtagsCard() {
|
||||
const [tags, setTags] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/feed/hashtags/trending', { params: { limit: 8 } })
|
||||
.then(({ data }) => setTags(Array.isArray(data.hashtags) ? data.hashtags : []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (!loading && tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<SideCard title="Trending Tags" icon="fa-solid fa-hashtag">
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{loading
|
||||
? [1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="animate-pulse flex items-center justify-between py-1.5">
|
||||
<div className="h-2.5 bg-white/10 rounded w-20" />
|
||||
<div className="h-2 bg-white/6 rounded w-10" />
|
||||
</div>
|
||||
))
|
||||
: tags.map((h) => (
|
||||
<a
|
||||
key={h.tag}
|
||||
href={`/tags/${h.tag}`}
|
||||
className="flex items-center justify-between group py-1.5 px-1 rounded-lg hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
<span className="text-sm text-slate-300 group-hover:text-sky-400 transition-colors font-medium">
|
||||
#{h.tag}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-600 tabular-nums">{h.post_count} posts</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<a
|
||||
href="/feed/trending"
|
||||
className="text-[12px] text-sky-500/70 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
See trending →
|
||||
</a>
|
||||
<a
|
||||
href="/feed/search"
|
||||
className="text-[12px] text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-magnifying-glass mr-1 text-[10px]" />
|
||||
Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Suggested to follow card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuggestionsCard({ excludeUsername, isLoggedIn }) {
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) { setLoading(false); return }
|
||||
axios.get('/api/search/users', { params: { q: '', per_page: 5 } })
|
||||
.then(({ data }) => {
|
||||
const list = (data.data ?? []).filter((u) => u.username !== excludeUsername).slice(0, 4)
|
||||
setUsers(list)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [excludeUsername, isLoggedIn])
|
||||
|
||||
if (!isLoggedIn) return null
|
||||
if (!loading && users.length === 0) return null
|
||||
|
||||
return (
|
||||
<SideCard title="Discover Creators" icon="fa-solid fa-compass">
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
{loading ? (
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 animate-pulse">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 shrink-0" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-2.5 bg-white/10 rounded w-24" />
|
||||
<div className="h-2 bg-white/6 rounded w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<a
|
||||
key={u.id}
|
||||
href={u.profile_url ?? `/@${u.username}`}
|
||||
className="flex items-center gap-2.5 group"
|
||||
>
|
||||
<img
|
||||
src={u.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={u.username}
|
||||
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
|
||||
{u.name || u.username}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-600 truncate">@{u.username}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-sky-500/80 group-hover:text-sky-400 transition-colors font-medium">
|
||||
View
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SideCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* FeedSidebar
|
||||
*
|
||||
* Props:
|
||||
* user object { id, username, name, uploads_count, ...}
|
||||
* profile object { bio, about, country, website, ... }
|
||||
* stats object from user_statistics
|
||||
* followerCount number
|
||||
* recentFollowers array [{ id, username, name, avatar_url, profile_url }]
|
||||
* socialLinks object keyed by platform
|
||||
* countryName string|null
|
||||
* isLoggedIn boolean
|
||||
* onTabChange function(tab)
|
||||
*/
|
||||
export default function FeedSidebar({
|
||||
user,
|
||||
profile,
|
||||
stats,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
isLoggedIn,
|
||||
onTabChange,
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StatsCard
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
user={user}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
<AboutCard
|
||||
user={user}
|
||||
profile={profile}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
/>
|
||||
|
||||
<RecentFollowersCard
|
||||
recentFollowers={recentFollowers}
|
||||
followerCount={followerCount}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
<SuggestionsCard
|
||||
excludeUsername={user?.username}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
|
||||
<TrendingHashtagsCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
resources/js/components/Feed/LinkPreviewCard.jsx
Normal file
96
resources/js/components/Feed/LinkPreviewCard.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* LinkPreviewCard
|
||||
* Renders an OG/OpenGraph link preview card.
|
||||
*
|
||||
* Props:
|
||||
* preview { url, title, description, image, site_name }
|
||||
* onDismiss function|null — if provided, shows a dismiss ✕ button
|
||||
* loading boolean — shows skeleton while fetching
|
||||
*/
|
||||
export default function LinkPreviewCard({ preview, onDismiss, loading = false }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden flex gap-3 p-3 animate-pulse">
|
||||
<div className="w-20 h-20 rounded-lg bg-white/10 shrink-0" />
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2 justify-center">
|
||||
<div className="h-3 bg-white/10 rounded w-2/3" />
|
||||
<div className="h-2.5 bg-white/10 rounded w-full" />
|
||||
<div className="h-2.5 bg-white/10 rounded w-4/5" />
|
||||
<div className="h-2 bg-white/[0.06] rounded w-1/3 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview?.url) return null
|
||||
|
||||
const domain = (() => {
|
||||
try { return new URL(preview.url).hostname.replace(/^www\./, '') }
|
||||
catch { return preview.site_name ?? '' }
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="relative rounded-xl border border-white/[0.08] bg-white/[0.03] overflow-hidden hover:border-white/[0.14] transition-colors group">
|
||||
<a
|
||||
href={preview.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="flex gap-0 items-stretch"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Image */}
|
||||
{preview.image ? (
|
||||
<div className="w-24 shrink-0 bg-white/5">
|
||||
<img
|
||||
src={preview.image}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.parentElement.style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 shrink-0 bg-white/[0.04] flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-link text-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0 px-3 py-2.5 flex flex-col justify-center gap-0.5">
|
||||
{preview.site_name && (
|
||||
<p className="text-[10px] uppercase tracking-wide text-sky-500/80 font-medium truncate">
|
||||
{preview.site_name}
|
||||
</p>
|
||||
)}
|
||||
{preview.title && (
|
||||
<p className="text-sm font-semibold text-white/90 line-clamp-2 leading-snug">
|
||||
{preview.title}
|
||||
</p>
|
||||
)}
|
||||
{preview.description && (
|
||||
<p className="text-xs text-slate-500 line-clamp-2 leading-relaxed mt-0.5">
|
||||
{preview.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-600 mt-1 truncate">
|
||||
{domain}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Dismiss button */}
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss() }}
|
||||
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-black/60 hover:bg-black/80 text-slate-400 hover:text-white flex items-center justify-center transition-colors text-[10px]"
|
||||
aria-label="Remove link preview"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
resources/js/components/Feed/PostActions.jsx
Normal file
139
resources/js/components/Feed/PostActions.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* PostActions: Like toggle, Comment toggle, Share menu, Report
|
||||
*/
|
||||
export default function PostActions({
|
||||
post,
|
||||
isLoggedIn,
|
||||
onCommentToggle,
|
||||
onReactionChange,
|
||||
}) {
|
||||
const [liked, setLiked] = useState(post.viewer_liked ?? false)
|
||||
const [likeCount, setLikeCount] = useState(post.reactions_count ?? 0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [shareMsg, setShareMsg] = useState(null)
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
if (liked) {
|
||||
await axios.delete(`/api/posts/${post.id}/reactions/like`)
|
||||
setLiked(false)
|
||||
setLikeCount((c) => Math.max(0, c - 1))
|
||||
onReactionChange?.({ liked: false, count: Math.max(0, likeCount - 1) })
|
||||
} else {
|
||||
await axios.post(`/api/posts/${post.id}/reactions`, { reaction: 'like' })
|
||||
setLiked(true)
|
||||
setLikeCount((c) => c + 1)
|
||||
onReactionChange?.({ liked: true, count: likeCount + 1 })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const url = `${window.location.origin}/@${post.author.username}?tab=posts&post=${post.id}`
|
||||
navigator.clipboard?.writeText(url)
|
||||
setShareMsg('Link copied!')
|
||||
setTimeout(() => setShareMsg(null), 2000)
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleReport = async () => {
|
||||
setMenuOpen(false)
|
||||
const reason = window.prompt('Why are you reporting this post? (required)')
|
||||
if (!reason?.trim()) return
|
||||
try {
|
||||
await axios.post(`/api/posts/${post.id}/report`, { reason: reason.trim() })
|
||||
alert('Report submitted. Thank you!')
|
||||
} catch (err) {
|
||||
if (err.response?.data?.message) {
|
||||
alert(err.response.data.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-slate-400 relative">
|
||||
{/* Like */}
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={busy}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
liked
|
||||
? 'text-sky-400 bg-sky-500/10 hover:bg-sky-500/20'
|
||||
: 'hover:bg-white/5 hover:text-slate-200'
|
||||
}`}
|
||||
title={liked ? 'Unlike' : 'Like'}
|
||||
aria-label={liked ? 'Unlike this post' : 'Like this post'}
|
||||
>
|
||||
<i className={`fa-${liked ? 'solid' : 'regular'} fa-heart fa-fw text-xs`} />
|
||||
<span className="tabular-nums">{likeCount > 0 && likeCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Comment toggle */}
|
||||
<button
|
||||
onClick={onCommentToggle}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm hover:bg-white/5 hover:text-slate-200 transition-colors"
|
||||
title="Comment"
|
||||
aria-label="Show comments"
|
||||
>
|
||||
<i className="fa-regular fa-comment fa-fw text-xs" />
|
||||
<span className="tabular-nums">{post.comments_count > 0 && post.comments_count}</span>
|
||||
</button>
|
||||
|
||||
{/* Share / More menu */}
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm hover:bg-white/5 hover:text-slate-200 transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<i className="fa-solid fa-ellipsis-h fa-fw text-xs" />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 bottom-full mb-1 w-44 rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden"
|
||||
onBlur={() => setMenuOpen(false)}
|
||||
>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw opacity-60" />
|
||||
Copy link
|
||||
</button>
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
onClick={handleReport}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-rose-400 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-flag fa-fw opacity-60" />
|
||||
Report post
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share feedback toast */}
|
||||
{shareMsg && (
|
||||
<span className="absolute -top-8 right-0 text-xs bg-slate-800 text-white px-2 py-1 rounded shadow">
|
||||
{shareMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
404
resources/js/components/Feed/PostCard.jsx
Normal file
404
resources/js/components/Feed/PostCard.jsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React, { useState } from 'react'
|
||||
import PostActions from './PostActions'
|
||||
import PostComments from './PostComments'
|
||||
import EmbeddedArtworkCard from './EmbeddedArtworkCard'
|
||||
import VisibilityPill from './VisibilityPill'
|
||||
import LinkPreviewCard from './LinkPreviewCard'
|
||||
|
||||
function formatRelative(isoString) {
|
||||
const diff = Date.now() - new Date(isoString).getTime()
|
||||
const s = Math.floor(diff / 1000)
|
||||
if (s < 60) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
function formatScheduledDate(isoString) {
|
||||
const d = new Date(isoString)
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Render plain text body with #hashtag links */
|
||||
function BodyWithHashtags({ html }) {
|
||||
// The body may already be sanitised HTML from the server. We replace
|
||||
// #tag patterns in text nodes (not inside existing anchor elements) with
|
||||
// anchor links pointing to /tags/{tag}.
|
||||
const processed = html.replace(
|
||||
/(?<!["\w])#([A-Za-z][A-Za-z0-9_]{1,63})/g,
|
||||
(_, tag) => `<a href="/tags/${tag.toLowerCase()}" class="text-sky-400 hover:underline">#${tag}</a>`,
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className="text-sm text-slate-300 leading-relaxed [&_a]:text-sky-400 [&_a]:hover:underline [&_strong]:text-white/90 [&_em]:text-slate-200"
|
||||
dangerouslySetInnerHTML={{ __html: processed }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCard
|
||||
* Renders a single post in the feed. Supports text + artwork_share types.
|
||||
*
|
||||
* Props:
|
||||
* post object (formatted by PostFeedService::formatPost)
|
||||
* isLoggedIn boolean
|
||||
* viewerUsername string|null
|
||||
* onDelete function(postId)
|
||||
* onUnsaved function(postId) — called when viewer unsaves this post
|
||||
*/
|
||||
export default function PostCard({ post, isLoggedIn = false, viewerUsername = null, onDelete, onUnsaved }) {
|
||||
const [showComments, setShowComments] = useState(false)
|
||||
const [postData, setPostData] = useState(post)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editBody, setEditBody] = useState(post.body ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(false)
|
||||
const [analytics, setAnalytics] = useState(null)
|
||||
|
||||
const isOwn = viewerUsername && post.author.username === viewerUsername
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
const { data } = await axios.patch(`/api/posts/${post.id}`, { body: editBody })
|
||||
setPostData(data.post)
|
||||
setEditMode(false)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this post?')) return
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
await axios.delete(`/api/posts/${post.id}`)
|
||||
onDelete?.(post.id)
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const handlePin = async () => {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.is_pinned) {
|
||||
await axios.delete(`/api/posts/${post.id}/pin`)
|
||||
setPostData((p) => ({ ...p, is_pinned: false, pinned_order: null }))
|
||||
} else {
|
||||
const { data } = await axios.post(`/api/posts/${post.id}/pin`)
|
||||
setPostData((p) => ({ ...p, is_pinned: true, pinned_order: data.pinned_order ?? 1 }))
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleSaveToggle = async () => {
|
||||
if (!isLoggedIn || saveLoading) return
|
||||
setSaveLoading(true)
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.viewer_saved) {
|
||||
await axios.delete(`/api/posts/${post.id}/save`)
|
||||
setPostData((p) => ({ ...p, viewer_saved: false, saves_count: Math.max(0, (p.saves_count ?? 1) - 1) }))
|
||||
onUnsaved?.(post.id)
|
||||
} else {
|
||||
await axios.post(`/api/posts/${post.id}/save`)
|
||||
setPostData((p) => ({ ...p, viewer_saved: true, saves_count: (p.saves_count ?? 0) + 1 }))
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setSaveLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAnalytics = async () => {
|
||||
if (!isOwn) return
|
||||
setAnalyticsOpen(true)
|
||||
if (!analytics) {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/${post.id}/analytics`)
|
||||
setAnalytics(data)
|
||||
} catch {
|
||||
setAnalytics(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.025] hover:border-white/10 transition-colors">
|
||||
{/* ── Pinned banner ──────────────────────────────────────────────── */}
|
||||
{postData.is_pinned && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-slate-500">
|
||||
<i className="fa-solid fa-thumbtack fa-fw text-sky-500/60" />
|
||||
<span>Pinned post</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scheduled banner (owner only) ─────────────────────────────── */}
|
||||
{isOwn && postData.status === 'scheduled' && postData.publish_at && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-500/80">
|
||||
<i className="fa-regular fa-clock fa-fw" />
|
||||
<span>Scheduled for {formatScheduledDate(postData.publish_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Achievement badge ──────────────────────────────────────────── */}
|
||||
{postData.type === 'achievement' && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-400/90">
|
||||
<i className="fa-solid fa-trophy fa-fw text-amber-400" />
|
||||
<span className="font-medium tracking-wide uppercase">Achievement unlocked</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 px-5 pt-4 pb-3">
|
||||
<a href={`/@${post.author.username}`} className="shrink-0">
|
||||
<img
|
||||
src={post.author.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={post.author.name}
|
||||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/@${post.author.username}`}
|
||||
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
{post.author.name || `@${post.author.username}`}
|
||||
</a>
|
||||
<span className="text-slate-600 text-xs">@{post.author.username}</span>
|
||||
{post.meta?.tagged_users?.length > 0 && (
|
||||
<span className="text-slate-600 text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="text-slate-700">with</span>
|
||||
{post.meta.tagged_users.map((u, i) => (
|
||||
<React.Fragment key={u.id}>
|
||||
{i > 0 && <span className="text-slate-700">,</span>}
|
||||
<a href={`/@${u.username}`} className="text-sky-500/80 hover:text-sky-400 transition-colors">
|
||||
@{u.username}
|
||||
</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 mt-0.5">
|
||||
<span>{formatRelative(post.created_at)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<VisibilityPill visibility={post.visibility} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right-side actions: save + owner menu */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Save / bookmark button */}
|
||||
{isLoggedIn && !isOwn && (
|
||||
<button
|
||||
onClick={handleSaveToggle}
|
||||
disabled={saveLoading}
|
||||
title={postData.viewer_saved ? 'Remove bookmark' : 'Save post'}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-lg transition-colors ${
|
||||
postData.viewer_saved
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-slate-600 hover:text-slate-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`${postData.viewer_saved ? 'fa-solid' : 'fa-regular'} fa-bookmark fa-fw text-sm`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Analytics for owner */}
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={handleOpenAnalytics}
|
||||
title="Post analytics"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-chart-simple fa-fw text-sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Owner menu */}
|
||||
{isOwn && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="text-slate-500 hover:text-slate-300 px-2 py-1 rounded-lg hover:bg-white/5 transition-colors"
|
||||
aria-label="Post options"
|
||||
>
|
||||
<i className="fa-solid fa-ellipsis-v fa-fw text-xs" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-40 rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setEditMode(true); setMenuOpen(false) }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw opacity-60" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePin}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className={`fa-solid fa-thumbtack fa-fw opacity-60 ${postData.is_pinned ? 'text-sky-400' : ''}`} />
|
||||
{postData.is_pinned ? 'Unpin post' : 'Pin post'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleDelete(); setMenuOpen(false) }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-rose-400 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-trash-can fa-fw opacity-60" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ─────────────────────────────────────────────────────────── */}
|
||||
<div className="px-5 pb-3 space-y-3">
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-xs transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="px-4 py-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 text-xs transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
postData.body && <BodyWithHashtags html={postData.body} />
|
||||
)}
|
||||
|
||||
{/* Hashtag pills */}
|
||||
{!editMode && postData.hashtags && postData.hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{postData.hashtags.map((tag) => (
|
||||
<a
|
||||
key={tag}
|
||||
href={`/tags/${tag}`}
|
||||
className="text-[11px] text-sky-500/80 hover:text-sky-400 hover:bg-sky-500/10 px-2 py-0.5 rounded-full border border-sky-500/20 hover:border-sky-500/40 transition-all"
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link preview (stored OG data) */}
|
||||
{!editMode && postData.meta?.link_preview?.url && (
|
||||
<LinkPreviewCard preview={postData.meta.link_preview} />
|
||||
)}
|
||||
|
||||
{/* Artwork share embed */}
|
||||
{postData.type === 'artwork_share' && postData.artwork && (
|
||||
<EmbeddedArtworkCard artwork={postData.artwork} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Actions ─────────────────────────────────────────────────────── */}
|
||||
<div className="border-t border-white/[0.04] px-5 py-2">
|
||||
<PostActions
|
||||
post={postData}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onCommentToggle={() => setShowComments((v) => !v)}
|
||||
onReactionChange={({ liked, count }) =>
|
||||
setPostData((p) => ({ ...p, viewer_liked: liked, reactions_count: count }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Comments ────────────────────────────────────────────────────── */}
|
||||
{showComments && (
|
||||
<div className="border-t border-white/[0.04] px-5 py-4">
|
||||
<PostComments
|
||||
postId={post.id}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isOwn={isOwn}
|
||||
initialCount={postData.comments_count}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Analytics modal ─────────────────────────────────────────────── */}
|
||||
{analyticsOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setAnalyticsOpen(false) }}
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-[#0d1628] p-6 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-white">Post Analytics</h3>
|
||||
<button
|
||||
onClick={() => setAnalyticsOpen(false)}
|
||||
className="text-slate-500 hover:text-white w-7 h-7 flex items-center justify-center rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!analytics ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<i className="fa-solid fa-spinner fa-spin text-slate-500 text-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Impressions', value: analytics.impressions?.toLocaleString() ?? '—', icon: 'fa-eye', color: 'text-sky-400' },
|
||||
{ label: 'Saves', value: analytics.saves?.toLocaleString() ?? '—', icon: 'fa-bookmark', color: 'text-amber-400' },
|
||||
{ label: 'Reactions', value: analytics.reactions?.toLocaleString() ?? '—', icon: 'fa-heart', color: 'text-rose-400' },
|
||||
{ label: 'Engagement', value: analytics.engagement_rate ? `${analytics.engagement_rate}%` : '—', icon: 'fa-chart-simple', color: 'text-emerald-400' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-xl bg-white/[0.04] border border-white/[0.06] px-4 py-3">
|
||||
<div className={`${item.color} text-sm mb-1`}>
|
||||
<i className={`fa-solid ${item.icon} fa-fw`} />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-white/90 tabular-nums leading-tight">{item.value}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
30
resources/js/components/Feed/PostCardSkeleton.jsx
Normal file
30
resources/js/components/Feed/PostCardSkeleton.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function PostCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5 animate-pulse space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-white/10 shrink-0" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<div className="h-3 bg-white/10 rounded w-28" />
|
||||
<div className="h-2 bg-white/6 rounded w-20" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-white/10 rounded w-full" />
|
||||
<div className="h-3 bg-white/8 rounded w-4/5" />
|
||||
<div className="h-3 bg-white/6 rounded w-2/3" />
|
||||
</div>
|
||||
{/* Artwork embed placeholder */}
|
||||
<div className="rounded-xl bg-white/5 aspect-[16/9]" />
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4 pt-1">
|
||||
<div className="h-3 bg-white/8 rounded w-12" />
|
||||
<div className="h-3 bg-white/6 rounded w-16" />
|
||||
<div className="h-3 bg-white/6 rounded w-10" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
222
resources/js/components/Feed/PostComments.jsx
Normal file
222
resources/js/components/Feed/PostComments.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
function formatRelative(isoString) {
|
||||
const diff = Date.now() - new Date(isoString).getTime()
|
||||
const s = Math.floor(diff / 1000)
|
||||
if (s < 60) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
export default function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
|
||||
const [comments, setComments] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const fetchComments = async (p = 1) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/${postId}/comments`, { params: { page: p } })
|
||||
setComments((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments(1)
|
||||
}, [postId])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!body.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await axios.post(`/api/posts/${postId}/comments`, { body })
|
||||
setComments((prev) => [...prev, data.comment])
|
||||
setBody('')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message ?? 'Failed to post comment.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (commentId) => {
|
||||
if (!window.confirm('Delete this comment?')) return
|
||||
try {
|
||||
await axios.delete(`/api/posts/${postId}/comments/${commentId}`)
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId))
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const handleHighlight = async (comment) => {
|
||||
try {
|
||||
if (comment.is_highlighted) {
|
||||
await axios.delete(`/api/posts/${postId}/comments/${comment.id}/highlight`)
|
||||
setComments((prev) =>
|
||||
prev.map((c) => c.id === comment.id ? { ...c, is_highlighted: false } : c),
|
||||
)
|
||||
} else {
|
||||
await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`)
|
||||
// Only one can be highlighted — clear others and set this one
|
||||
setComments((prev) =>
|
||||
prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id })),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
// Highlighted comment always first (server also orders this way, but keep client in sync)
|
||||
const sorted = [...comments].sort((a, b) =>
|
||||
(b.is_highlighted ? 1 : 0) - (a.is_highlighted ? 1 : 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Comment list */}
|
||||
{!loaded && loading && (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex gap-2 animate-pulse">
|
||||
<div className="w-7 h-7 rounded-full bg-white/10 shrink-0" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-2.5 bg-white/10 rounded w-24" />
|
||||
<div className="h-2 bg-white/6 rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loaded && sorted.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex gap-2 group ${c.is_highlighted ? 'rounded-xl bg-sky-500/5 border border-sky-500/15 px-3 py-2 -mx-3' : ''}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<a href={`/@${c.author.username}`} className="shrink-0">
|
||||
<img
|
||||
src={c.author.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={c.author.name}
|
||||
className="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/@${c.author.username}`}
|
||||
className="text-xs font-semibold text-white/80 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
{c.author.name || `@${c.author.username}`}
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-600">{formatRelative(c.created_at)}</span>
|
||||
{c.is_highlighted && (
|
||||
<span className="text-[10px] text-sky-400 font-medium flex items-center gap-1">
|
||||
<i className="fa-solid fa-star fa-xs" />
|
||||
Highlighted by author
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-slate-300 mt-0.5 [&_a]:text-sky-400 [&_a]:hover:underline"
|
||||
dangerouslySetInnerHTML={{ __html: c.body }}
|
||||
/>
|
||||
</div>
|
||||
{/* Actions: highlight (owner) + delete */}
|
||||
<div className="flex items-start gap-1 opacity-0 group-hover:opacity-100 transition-all ml-1">
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={() => handleHighlight(c)}
|
||||
title={c.is_highlighted ? 'Remove highlight' : 'Highlight comment'}
|
||||
className={`text-xs transition-colors px-1 py-0.5 rounded ${
|
||||
c.is_highlighted
|
||||
? 'text-sky-400 hover:text-slate-400'
|
||||
: 'text-slate-600 hover:text-sky-400'
|
||||
}`}
|
||||
>
|
||||
<i className={`${c.is_highlighted ? 'fa-solid' : 'fa-regular'} fa-star`} />
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
className="text-slate-600 hover:text-rose-400 transition-all text-xs"
|
||||
title="Delete comment"
|
||||
>
|
||||
<i className="fa-solid fa-trash-can" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loaded && hasMore && (
|
||||
<button
|
||||
onClick={() => fetchComments(page + 1)}
|
||||
disabled={loading}
|
||||
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more comments'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
{isLoggedIn ? (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 mt-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write a comment…"
|
||||
maxLength={1000}
|
||||
rows={1}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !body.trim()}
|
||||
className="px-3 py-2 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm transition-colors"
|
||||
>
|
||||
{submitting ? <i className="fa-solid fa-spinner fa-spin" /> : <i className="fa-solid fa-paper-plane" />}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
<a href="/login" className="text-sky-400 hover:underline">Sign in</a> to comment.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-rose-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
456
resources/js/components/Feed/PostComposer.jsx
Normal file
456
resources/js/components/Feed/PostComposer.jsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, lazy, Suspense } from 'react'
|
||||
import axios from 'axios'
|
||||
import ShareArtworkModal from './ShareArtworkModal'
|
||||
import LinkPreviewCard from './LinkPreviewCard'
|
||||
import TagPeopleModal from './TagPeopleModal'
|
||||
|
||||
// Lazy-load the heavy emoji picker only when first opened
|
||||
// @emoji-mart/react only has a default export (the Picker); m.Picker is undefined
|
||||
const EmojiPicker = lazy(() => import('@emoji-mart/react'))
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', icon: 'fa-globe', label: 'Public' },
|
||||
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
|
||||
{ value: 'private', icon: 'fa-lock', label: 'Private' },
|
||||
]
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s\])"'>]{4,}/gi
|
||||
|
||||
function extractFirstUrl(text) {
|
||||
const m = text.match(URL_RE)
|
||||
return m ? m[0].replace(/[.,;:!?)]+$/, '') : null
|
||||
}
|
||||
|
||||
/**
|
||||
* PostComposer
|
||||
*
|
||||
* Props:
|
||||
* user object { id, username, name, avatar }
|
||||
* onPosted function(newPost)
|
||||
*/
|
||||
export default function PostComposer({ user, onPosted }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [body, setBody] = useState('')
|
||||
const [visibility, setVisibility] = useState('public')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [shareModal, setShareModal] = useState(false)
|
||||
const [linkPreview, setLinkPreview] = useState(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewDismissed, setPreviewDismissed] = useState(false)
|
||||
const [lastPreviewUrl, setLastPreviewUrl] = useState(null)
|
||||
const [emojiOpen, setEmojiOpen] = useState(false)
|
||||
const [emojiData, setEmojiData] = useState(null) // loaded lazily
|
||||
const [tagModal, setTagModal] = useState(false)
|
||||
const [taggedUsers, setTaggedUsers] = useState([]) // [{ id, username, name, avatar_url }]
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false)
|
||||
const [scheduledAt, setScheduledAt] = useState('') // ISO datetime-local string
|
||||
|
||||
const textareaRef = useRef(null)
|
||||
const debounceTimer = useRef(null)
|
||||
const emojiWrapRef = useRef(null) // wraps button + popover for outside-click
|
||||
|
||||
// Load emoji-mart data lazily the first time the picker opens
|
||||
const openEmojiPicker = useCallback(async () => {
|
||||
if (!emojiData) {
|
||||
const { default: data } = await import('@emoji-mart/data')
|
||||
setEmojiData(data)
|
||||
}
|
||||
setEmojiOpen((v) => !v)
|
||||
}, [emojiData])
|
||||
|
||||
// Close picker on outside click
|
||||
useEffect(() => {
|
||||
if (!emojiOpen) return
|
||||
const handler = (e) => {
|
||||
if (emojiWrapRef.current && !emojiWrapRef.current.contains(e.target)) {
|
||||
setEmojiOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [emojiOpen])
|
||||
|
||||
// Insert emoji at current cursor position
|
||||
const insertEmoji = useCallback((emoji) => {
|
||||
const native = emoji.native ?? emoji.shortcodes ?? ''
|
||||
const ta = textareaRef.current
|
||||
if (!ta) {
|
||||
setBody((b) => b + native)
|
||||
return
|
||||
}
|
||||
const start = ta.selectionStart ?? body.length
|
||||
const end = ta.selectionEnd ?? body.length
|
||||
const next = body.slice(0, start) + native + body.slice(end)
|
||||
setBody(next)
|
||||
// Restore cursor after the inserted emoji
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
const pos = start + native.length
|
||||
ta.setSelectionRange(pos, pos)
|
||||
})
|
||||
setEmojiOpen(false)
|
||||
}, [body])
|
||||
|
||||
const handleFocus = () => {
|
||||
setExpanded(true)
|
||||
setTimeout(() => textareaRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const fetchLinkPreview = useCallback(async (url) => {
|
||||
setPreviewLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/link-preview', { params: { url } })
|
||||
if (data?.url) {
|
||||
setLinkPreview(data)
|
||||
}
|
||||
} catch {
|
||||
// silently ignore – preview is optional
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleBodyChange = (e) => {
|
||||
const val = e.target.value
|
||||
setBody(val)
|
||||
|
||||
// Detect URLs and auto-fetch preview (debounced)
|
||||
clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
const url = extractFirstUrl(val)
|
||||
if (!url || previewDismissed) return
|
||||
if (url === lastPreviewUrl) return
|
||||
setLastPreviewUrl(url)
|
||||
setLinkPreview(null)
|
||||
fetchLinkPreview(url)
|
||||
}, 700)
|
||||
}
|
||||
|
||||
const handleDismissPreview = () => {
|
||||
setLinkPreview(null)
|
||||
setPreviewDismissed(true)
|
||||
}
|
||||
|
||||
const resetComposer = () => {
|
||||
setBody('')
|
||||
setExpanded(false)
|
||||
setLinkPreview(null)
|
||||
setPreviewLoading(false)
|
||||
setPreviewDismissed(false)
|
||||
setLastPreviewUrl(null)
|
||||
setEmojiOpen(false)
|
||||
setTaggedUsers([])
|
||||
setTagModal(false)
|
||||
setScheduleOpen(false)
|
||||
setScheduledAt('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e?.preventDefault()
|
||||
if (!body.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await axios.post('/api/posts', {
|
||||
type: 'text',
|
||||
visibility,
|
||||
body,
|
||||
link_preview: linkPreview ?? undefined,
|
||||
tagged_users: taggedUsers.length > 0 ? taggedUsers.map(({ id, username, name }) => ({ id, username, name })) : undefined,
|
||||
publish_at: scheduledAt || undefined,
|
||||
})
|
||||
onPosted?.(data.post)
|
||||
resetComposer()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message ?? 'Failed to post.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShared = (newPost) => {
|
||||
onPosted?.(newPost)
|
||||
setShareModal(false)
|
||||
}
|
||||
|
||||
const showPreview = (linkPreview || previewLoading) && !previewDismissed
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] px-5 py-4">
|
||||
{/* Collapsed: click-to-expand placeholder */}
|
||||
{!expanded ? (
|
||||
<div
|
||||
onClick={handleFocus}
|
||||
className="flex items-center gap-3 cursor-text"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFocus()}
|
||||
aria-label="Create a post"
|
||||
>
|
||||
<img
|
||||
src={user.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={user.name}
|
||||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="text-sm text-slate-500 flex-1 bg-white/[0.04] rounded-xl px-4 py-2.5 hover:bg-white/[0.07] transition-colors">
|
||||
What's on your mind, {user.name?.split(' ')[0] ?? user.username}?
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* Textarea */}
|
||||
<div className="flex gap-3">
|
||||
<a href={`/@${user.username}`} className="shrink-0" tabIndex={-1}>
|
||||
<img
|
||||
src={user.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={user.name}
|
||||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all mt-0.5"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
|
||||
{/* User identity byline */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={`/@${user.username}`}
|
||||
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors leading-tight"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{user.name || `@${user.username}`}
|
||||
</a>
|
||||
<span className="text-xs text-slate-500 leading-tight">@{user.username}</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={handleBodyChange}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
placeholder="What's on your mind?"
|
||||
autoFocus
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagged people pills */}
|
||||
{taggedUsers.length > 0 && (
|
||||
<div className="pl-12 flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-xs text-slate-500">With:</span>
|
||||
{taggedUsers.map((u) => (
|
||||
<span key={u.id} className="flex items-center gap-1 px-2 py-0.5 bg-sky-500/10 border border-sky-500/20 rounded-full text-xs text-sky-400">
|
||||
<img src={u.avatar_url ?? '/images/avatar_default.webp'} alt="" className="w-3.5 h-3.5 rounded-full object-cover" />
|
||||
@{u.username}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaggedUsers((prev) => prev.filter((x) => x.id !== u.id))}
|
||||
className="opacity-60 hover:opacity-100 ml-0.5"
|
||||
>
|
||||
<i className="fa-solid fa-xmark fa-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link preview */}
|
||||
{showPreview && (
|
||||
<div className="pl-12">
|
||||
<LinkPreviewCard
|
||||
preview={linkPreview}
|
||||
loading={previewLoading && !linkPreview}
|
||||
onDismiss={handleDismissPreview}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule date picker */}
|
||||
{scheduleOpen && (
|
||||
<div className="pl-12">
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
|
||||
<div className="flex-1">
|
||||
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
|
||||
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
{Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
</p>
|
||||
</div>
|
||||
{scheduledAt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduledAt('')}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Clear"
|
||||
>
|
||||
<i className="fa-solid fa-xmark fa-sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer row */}
|
||||
<div className="flex items-center gap-2 pl-12">
|
||||
{/* Visibility selector */}
|
||||
<div className="flex gap-1">
|
||||
{VISIBILITY_OPTIONS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
type="button"
|
||||
onClick={() => setVisibility(v.value)}
|
||||
title={v.label}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
visibility === v.value
|
||||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid ${v.icon} fa-fw`} />
|
||||
{visibility === v.value && <span>{v.label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Emoji picker trigger */}
|
||||
<div ref={emojiWrapRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEmojiPicker}
|
||||
title="Add emoji"
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
emojiOpen
|
||||
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-regular fa-face-smile fa-fw" />
|
||||
</button>
|
||||
|
||||
{emojiOpen && (
|
||||
<div className="absolute bottom-full mb-2 left-0 z-50 shadow-2xl">
|
||||
<Suspense fallback={
|
||||
<div className="w-[352px] h-[400px] rounded-2xl bg-[#10192e] border border-white/10 flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-spinner fa-spin text-xl" />
|
||||
</div>
|
||||
}>
|
||||
{emojiData && (
|
||||
<EmojiPicker
|
||||
data={emojiData}
|
||||
onEmojiSelect={insertEmoji}
|
||||
theme="dark"
|
||||
set="native"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
navPosition="bottom"
|
||||
perLine={9}
|
||||
maxFrequentRows={2}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag people button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagModal(true)}
|
||||
title="Tag people"
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
taggedUsers.length > 0
|
||||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-solid fa-user-tag fa-fw" />
|
||||
{taggedUsers.length > 0 && <span>{taggedUsers.length}</span>}
|
||||
</button>
|
||||
|
||||
{/* Schedule button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduleOpen((v) => !v)}
|
||||
title="Schedule post"
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||||
scheduleOpen || scheduledAt
|
||||
? 'bg-violet-500/15 text-violet-400 border border-violet-500/30'
|
||||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className="fa-regular fa-clock fa-fw" />
|
||||
{scheduledAt && <span className="max-w-[80px] truncate">{new Date(scheduledAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>}
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Share artwork button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShareModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
|
||||
title="Share an artwork"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
Share artwork
|
||||
</button>
|
||||
|
||||
{/* Cancel */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetComposer}
|
||||
className="px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{/* Post */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !body.trim()}
|
||||
className={`px-4 py-1.5 rounded-xl disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors ${
|
||||
scheduledAt ? 'bg-violet-600 hover:bg-violet-500' : 'bg-sky-600 hover:bg-sky-500'
|
||||
}`}
|
||||
>
|
||||
{submitting ? 'Posting…' : scheduledAt ? 'Schedule' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Char count */}
|
||||
{body.length > 1800 && (
|
||||
<p className="text-right text-[10px] text-amber-400/70 pr-1">{body.length}/2000</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share artwork modal */}
|
||||
<ShareArtworkModal
|
||||
isOpen={shareModal}
|
||||
onClose={() => setShareModal(false)}
|
||||
onShared={handleShared}
|
||||
/>
|
||||
|
||||
{/* Tag people modal */}
|
||||
<TagPeopleModal
|
||||
isOpen={tagModal}
|
||||
onClose={() => setTagModal(false)}
|
||||
selected={taggedUsers}
|
||||
onConfirm={(users) => { setTaggedUsers(users); setTagModal(false) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
284
resources/js/components/Feed/ShareArtworkModal.jsx
Normal file
284
resources/js/components/Feed/ShareArtworkModal.jsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', icon: 'fa-globe', label: 'Public' },
|
||||
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
|
||||
{ value: 'private', icon: 'fa-lock', label: 'Private' },
|
||||
]
|
||||
|
||||
function ArtworkResult({ artwork, onSelect }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(artwork)}
|
||||
className="w-full flex gap-3 p-3 rounded-xl hover:bg-white/5 transition-colors text-left group"
|
||||
>
|
||||
<div className="w-14 h-12 rounded-lg overflow-hidden shrink-0 bg-white/5">
|
||||
{artwork.thumb_url ? (
|
||||
<img
|
||||
src={artwork.thumb_url}
|
||||
alt={artwork.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate group-hover:text-sky-400 transition-colors">
|
||||
{artwork.title}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5 truncate">
|
||||
by {artwork.user?.name ?? artwork.author_name ?? 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ShareArtworkModal
|
||||
*
|
||||
* Props:
|
||||
* isOpen boolean
|
||||
* onClose function
|
||||
* onShared function(newPost)
|
||||
* preselectedArtwork object|null (share from artwork page)
|
||||
*/
|
||||
export default function ShareArtworkModal({ isOpen, onClose, onShared, preselectedArtwork = null }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [selected, setSelected] = useState(preselectedArtwork)
|
||||
const [body, setBody] = useState('')
|
||||
const [visibility, setVisibility] = useState('public')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const searchTimer = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (isOpen && !preselectedArtwork) {
|
||||
setTimeout(() => inputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(preselectedArtwork)
|
||||
}, [preselectedArtwork])
|
||||
|
||||
const handleSearch = (q) => {
|
||||
setQuery(q)
|
||||
clearTimeout(searchTimer.current)
|
||||
if (!q.trim()) { setResults([]); return }
|
||||
searchTimer.current = setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/search/artworks', {
|
||||
params: { q, shareable: 1, per_page: 12 },
|
||||
})
|
||||
setResults(data.data ?? data.hits ?? [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { data } = await axios.post(`/api/posts/share/artwork/${selected.id}`, {
|
||||
body: body.trim() || null,
|
||||
visibility,
|
||||
})
|
||||
onShared?.(data.post)
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.errors?.artwork_id?.[0] ?? err.response?.data?.message ?? 'Failed to share.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSelected(preselectedArtwork)
|
||||
setBody('')
|
||||
setVisibility('public')
|
||||
setError(null)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Share artwork"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative w-full max-w-lg bg-[#0d1829] border border-white/10 rounded-2xl shadow-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
||||
<h2 className="text-sm font-semibold text-white/90">
|
||||
<i className="fa-solid fa-share-nodes mr-2 text-sky-400 opacity-80" />
|
||||
Share Artwork to Profile
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-slate-500 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{/* Artwork search / selected */}
|
||||
{!selected ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
Search for an artwork
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Type artwork name…"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
/>
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs" />
|
||||
{searching && (
|
||||
<i className="fa-solid fa-spinner fa-spin absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs" />
|
||||
)}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="mt-2 rounded-xl border border-white/[0.06] bg-black/20 max-h-56 overflow-y-auto">
|
||||
{results.map((a) => (
|
||||
<ArtworkResult key={a.id} artwork={a} onSelect={(art) => { setSelected(art); setQuery(''); setResults([]) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{query && !searching && results.length === 0 && (
|
||||
<p className="text-xs text-slate-500 mt-2 text-center py-4">No artworks found.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">Selected Artwork</label>
|
||||
<div className="flex gap-3 rounded-xl border border-white/[0.08] bg-black/20 p-3">
|
||||
<div className="w-16 h-14 rounded-lg overflow-hidden shrink-0 bg-white/5">
|
||||
<img
|
||||
src={selected.thumb_url ?? selected.thumb ?? ''}
|
||||
alt={selected.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">{selected.title}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
by {selected.user?.name ?? selected.author?.name ?? selected.author_name ?? 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
{!preselectedArtwork && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-slate-500 hover:text-white transition-colors self-start"
|
||||
title="Change artwork"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commentary */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
Commentary <span className="text-slate-600">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
placeholder="Say something about this artwork…"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
/>
|
||||
<p className="text-right text-[10px] text-slate-600 mt-0.5">{body.length}/2000</p>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">Visibility</label>
|
||||
<div className="flex gap-2">
|
||||
{VISIBILITY_OPTIONS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => setVisibility(v.value)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-all border ${
|
||||
visibility === v.value
|
||||
? 'border-sky-500/50 bg-sky-500/10 text-sky-300'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid ${v.icon} fa-fw`} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400 bg-rose-500/10 border border-rose-500/20 rounded-xl px-3 py-2">
|
||||
<i className="fa-solid fa-circle-exclamation mr-1.5" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 px-5 py-4 border-t border-white/[0.06]">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !selected}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{submitting
|
||||
? <><i className="fa-solid fa-spinner fa-spin" /> Sharing…</>
|
||||
: <><i className="fa-solid fa-share-nodes" /> Share</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
204
resources/js/components/Feed/TagPeopleModal.jsx
Normal file
204
resources/js/components/Feed/TagPeopleModal.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* TagPeopleModal
|
||||
*
|
||||
* Props:
|
||||
* isOpen boolean
|
||||
* onClose function()
|
||||
* selected array [{ id, username, name, avatar_url }]
|
||||
* onConfirm function(selectedArray)
|
||||
*/
|
||||
export default function TagPeopleModal({ isOpen, onClose, selected = [], onConfirm }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [staged, setStaged] = useState(selected)
|
||||
const inputRef = useRef(null)
|
||||
const debounce = useRef(null)
|
||||
|
||||
// Re-sync staged list when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStaged(selected)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setTimeout(() => inputRef.current?.focus(), 80)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const search = useCallback(async (q) => {
|
||||
if (q.length < 2) { setResults([]); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await axios.get('/api/search/users', { params: { q, per_page: 8 } })
|
||||
setResults(data.data ?? [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleQueryChange = (e) => {
|
||||
const val = e.target.value
|
||||
setQuery(val)
|
||||
clearTimeout(debounce.current)
|
||||
debounce.current = setTimeout(() => search(val.replace(/^@/, '')), 300)
|
||||
}
|
||||
|
||||
const isSelected = (user) => staged.some((u) => u.id === user.id)
|
||||
|
||||
const toggle = (user) => {
|
||||
setStaged((prev) =>
|
||||
isSelected(user)
|
||||
? prev.filter((u) => u.id !== user.id)
|
||||
: prev.length < 10 ? [...prev, user] : prev
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(staged)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-white/10 bg-[#0c1525] shadow-2xl overflow-hidden flex flex-col max-h-[80vh]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-white/[0.07]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
|
||||
aria-label="Back"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left fa-sm" />
|
||||
</button>
|
||||
<h2 className="flex-1 text-center text-base font-semibold text-white/90 -ml-8">
|
||||
Tag people
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="text-sm font-medium text-sky-400 hover:text-sky-300 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="px-4 py-3 border-b border-white/[0.05]">
|
||||
<div className="flex items-center gap-2 bg-white/[0.06] border border-white/[0.08] rounded-xl px-3 py-2">
|
||||
<i className="fa-solid fa-magnifying-glass text-slate-500 text-xs fa-fw" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
placeholder="Search users…"
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-slate-600 focus:outline-none"
|
||||
/>
|
||||
{loading && <i className="fa-solid fa-spinner fa-spin text-slate-500 text-xs" />}
|
||||
{query && !loading && (
|
||||
<button type="button" onClick={() => { setQuery(''); setResults([]) }} className="text-slate-500 hover:text-white">
|
||||
<i className="fa-solid fa-xmark text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected chips */}
|
||||
{staged.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 py-3 border-b border-white/[0.05]">
|
||||
{staged.map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-sky-500/15 border border-sky-500/30 rounded-full text-xs text-sky-400"
|
||||
>
|
||||
<img
|
||||
src={u.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded-full object-cover"
|
||||
/>
|
||||
@{u.username}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u)}
|
||||
className="ml-0.5 opacity-60 hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove @${u.username}`}
|
||||
>
|
||||
<i className="fa-solid fa-xmark fa-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{results.length === 0 && query.length >= 2 && !loading && (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-600">No users found for "{query}"</p>
|
||||
)}
|
||||
{results.length === 0 && query.length < 2 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-600">Type a name or @username to search</p>
|
||||
)}
|
||||
{results.map((u) => {
|
||||
const checked = isSelected(u)
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => toggle(u)}
|
||||
className={`w-full flex items-center gap-3 px-5 py-3 text-left transition-colors ${
|
||||
checked ? 'bg-sky-500/10' : 'hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={u.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={u.username}
|
||||
className="w-10 h-10 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">
|
||||
{u.name || `@${u.username}`}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 truncate">@{u.username}</p>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
checked
|
||||
? 'bg-sky-500 border-sky-500 text-white'
|
||||
: 'border-white/20'
|
||||
}`}>
|
||||
{checked && <i className="fa-solid fa-check fa-xs" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer confirm */}
|
||||
<div className="px-5 py-3 border-t border-white/[0.07]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="w-full py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors disabled:opacity-40"
|
||||
>
|
||||
{staged.length === 0
|
||||
? 'Continue without tagging'
|
||||
: `Tag ${staged.length} ${staged.length === 1 ? 'person' : 'people'}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
resources/js/components/Feed/VisibilityPill.jsx
Normal file
22
resources/js/components/Feed/VisibilityPill.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
const ICONS = {
|
||||
public: { icon: 'fa-globe', label: 'Public', cls: 'text-slate-500' },
|
||||
followers: { icon: 'fa-user-friends', label: 'Followers', cls: 'text-sky-500/70' },
|
||||
private: { icon: 'fa-lock', label: 'Private', cls: 'text-amber-500/70' },
|
||||
}
|
||||
|
||||
export default function VisibilityPill({ visibility, showLabel = false }) {
|
||||
const v = ICONS[visibility] ?? ICONS.public
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs ${v.cls}`}
|
||||
title={v.label}
|
||||
aria-label={`Visibility: ${v.label}`}
|
||||
>
|
||||
<i className={`fa-solid ${v.icon} fa-fw`} />
|
||||
{showLabel && <span>{v.label}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user