update
This commit is contained in:
@@ -107,6 +107,7 @@
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
position: relative;
|
||||
background: theme('colors.white / 4%');
|
||||
border: 1px solid theme('colors.white / 6%');
|
||||
border-radius: 0.75rem;
|
||||
@@ -115,6 +116,7 @@
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
@@ -122,6 +124,65 @@
|
||||
color: theme('colors.zinc.300');
|
||||
}
|
||||
|
||||
.tiptap .hljs {
|
||||
color: theme('colors.zinc.200');
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tiptap .hljs-comment,
|
||||
.tiptap .hljs-quote {
|
||||
color: theme('colors.zinc.500');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tiptap .hljs-keyword,
|
||||
.tiptap .hljs-selector-tag,
|
||||
.tiptap .hljs-subst {
|
||||
color: theme('colors.fuchsia.300');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tiptap .hljs-string,
|
||||
.tiptap .hljs-attr,
|
||||
.tiptap .hljs-template-tag,
|
||||
.tiptap .hljs-template-variable {
|
||||
color: theme('colors.sky.300');
|
||||
}
|
||||
|
||||
.tiptap .hljs-title,
|
||||
.tiptap .hljs-section,
|
||||
.tiptap .hljs-name,
|
||||
.tiptap .hljs-selector-id,
|
||||
.tiptap .hljs-selector-class {
|
||||
color: theme('colors.blue.300');
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tiptap .hljs-number,
|
||||
.tiptap .hljs-literal,
|
||||
.tiptap .hljs-symbol,
|
||||
.tiptap .hljs-bullet {
|
||||
color: theme('colors.amber.300');
|
||||
}
|
||||
|
||||
.tiptap .hljs-built_in,
|
||||
.tiptap .hljs-type,
|
||||
.tiptap .hljs-class {
|
||||
color: theme('colors.rose.300');
|
||||
}
|
||||
|
||||
.tiptap .hljs-variable,
|
||||
.tiptap .hljs-property,
|
||||
.tiptap .hljs-params,
|
||||
.tiptap .hljs-operator {
|
||||
color: theme('colors.zinc.200');
|
||||
}
|
||||
|
||||
.tiptap .hljs-meta,
|
||||
.tiptap .hljs-doctag {
|
||||
color: theme('colors.zinc.400');
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid theme('colors.sky.500 / 40%');
|
||||
padding-left: 1rem;
|
||||
@@ -160,6 +221,204 @@
|
||||
color: theme('colors.sky.200');
|
||||
}
|
||||
|
||||
.story-prose pre {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgba(51, 65, 85, 0.95) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%) !important;
|
||||
box-shadow:
|
||||
0 26px 75px rgba(2, 6, 23, 0.5),
|
||||
inset 0 1px 0 rgba(56, 189, 248, 0.08);
|
||||
padding: 4rem 1.5rem 1.5rem !important;
|
||||
}
|
||||
|
||||
.story-prose pre[data-language]::before {
|
||||
content: attr(data-language);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0.7rem;
|
||||
left: 1.25rem;
|
||||
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
padding: 0.18rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(125 211 252);
|
||||
}
|
||||
|
||||
.story-prose pre::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3rem 0 auto 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.28), rgba(56, 189, 248, 0));
|
||||
}
|
||||
|
||||
.story-code-copy-button {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0.7rem;
|
||||
right: 1.25rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.32);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
padding: 0.35rem 0.78rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgb(241 245 249);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.story-code-copy-button:hover {
|
||||
background: rgb(14 165 233);
|
||||
border-color: rgb(56 189 248);
|
||||
color: rgb(2 6 23);
|
||||
box-shadow: 0 10px 24px rgba(14, 165, 233, 0.22);
|
||||
}
|
||||
|
||||
.story-code-copy-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.story-code-copy-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.story-code-copy-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.story-code-copy-button[data-copied='true'] {
|
||||
background: rgb(14 116 144);
|
||||
border-color: rgb(34 211 238);
|
||||
color: white;
|
||||
box-shadow: 0 12px 28px rgba(14, 116, 144, 0.22);
|
||||
}
|
||||
|
||||
.story-code-copy-button[data-copied='false'] {
|
||||
background: rgb(153 27 27);
|
||||
border-color: rgb(248 113 113);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.story-code-copy-button[data-copied='true'] .story-code-copy-icon {
|
||||
animation: story-code-copy-check 320ms cubic-bezier(.2,.9,.25,1.2);
|
||||
}
|
||||
|
||||
.story-code-copy-button[data-copied='true'] .story-code-copy-label {
|
||||
animation: story-code-copy-label 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes story-code-copy-check {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) rotate(-24deg);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2) rotate(6deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes story-code-copy-label {
|
||||
0% {
|
||||
opacity: 0.15;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.story-prose pre code.hljs,
|
||||
.story-prose pre code[class*='language-'] {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.story-prose .hljs {
|
||||
color: rgb(226 232 240);
|
||||
background: transparent;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.story-prose .hljs-comment,
|
||||
.story-prose .hljs-quote {
|
||||
color: rgb(100 116 139);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.story-prose .hljs-keyword,
|
||||
.story-prose .hljs-selector-tag,
|
||||
.story-prose .hljs-subst {
|
||||
color: rgb(125 211 252);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.story-prose .hljs-string,
|
||||
.story-prose .hljs-attr,
|
||||
.story-prose .hljs-template-tag,
|
||||
.story-prose .hljs-template-variable {
|
||||
color: rgb(134 239 172);
|
||||
}
|
||||
|
||||
.story-prose .hljs-title,
|
||||
.story-prose .hljs-section,
|
||||
.story-prose .hljs-name,
|
||||
.story-prose .hljs-selector-id,
|
||||
.story-prose .hljs-selector-class {
|
||||
color: rgb(196 181 253);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.story-prose .hljs-number,
|
||||
.story-prose .hljs-literal,
|
||||
.story-prose .hljs-symbol,
|
||||
.story-prose .hljs-bullet {
|
||||
color: rgb(251 191 36);
|
||||
}
|
||||
|
||||
.story-prose .hljs-built_in,
|
||||
.story-prose .hljs-type,
|
||||
.story-prose .hljs-class {
|
||||
color: rgb(244 114 182);
|
||||
}
|
||||
|
||||
.story-prose .hljs-variable,
|
||||
.story-prose .hljs-property,
|
||||
.story-prose .hljs-params,
|
||||
.story-prose .hljs-operator {
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
|
||||
.story-prose .hljs-meta,
|
||||
.story-prose .hljs-doctag {
|
||||
color: rgb(71 85 105);
|
||||
}
|
||||
|
||||
/* ─── @mention pills ─── */
|
||||
.tiptap .mention,
|
||||
.mention {
|
||||
|
||||
@@ -108,7 +108,7 @@ function CommunityActivityPage({
|
||||
const params = new URLSearchParams({ filter, page: String(page) })
|
||||
if (initialUserId) params.set('user_id', String(initialUserId))
|
||||
|
||||
const response = await fetch(`/api/community/activity?${params.toString()}`, {
|
||||
const response = await fetch(`/api/activity?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
108
resources/js/Pages/Leaderboard/LeaderboardPage.jsx
Normal file
108
resources/js/Pages/Leaderboard/LeaderboardPage.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import LeaderboardTabs from '../../components/leaderboard/LeaderboardTabs'
|
||||
import LeaderboardList from '../../components/leaderboard/LeaderboardList'
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ value: 'creator', label: 'Creators' },
|
||||
{ value: 'artwork', label: 'Artworks' },
|
||||
{ value: 'story', label: 'Stories' },
|
||||
]
|
||||
|
||||
const PERIOD_TABS = [
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'all_time', label: 'All-time' },
|
||||
]
|
||||
|
||||
const API_BY_TYPE = {
|
||||
creator: '/api/leaderboard/creators',
|
||||
artwork: '/api/leaderboard/artworks',
|
||||
story: '/api/leaderboard/stories',
|
||||
}
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const { props } = usePage()
|
||||
const { initialType = 'creator', initialPeriod = 'weekly', initialData = { items: [] }, meta = {} } = props
|
||||
|
||||
const [type, setType] = useState(initialType)
|
||||
const [period, setPeriod] = useState(initialPeriod)
|
||||
const [data, setData] = useState(initialData)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (type === initialType && period === initialPeriod) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await window.axios.get(`${API_BY_TYPE[type]}?period=${period}`)
|
||||
if (!cancelled && response.data) {
|
||||
setData(response.data)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('type', type === 'creator' ? 'creators' : `${type}s`)
|
||||
url.searchParams.set('period', period === 'all_time' ? 'all' : period)
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
} catch (_) {}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [type, period, initialType, initialPeriod])
|
||||
|
||||
const items = Array.isArray(data?.items) ? data.items : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{meta?.title || 'Leaderboard | Skinbase'}</title>
|
||||
<meta name="description" content={meta?.description || 'Top creators, artworks, and stories on Skinbase.'} />
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<header className="rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-300">Skinbase Competition Board</p>
|
||||
<h1 className="mt-4 max-w-3xl text-4xl font-black tracking-tight text-white sm:text-5xl">
|
||||
Top creators, standout artworks, and stories with momentum.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base">
|
||||
Switch between creators, artworks, and stories, then filter by daily, weekly, monthly, or all-time performance.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<LeaderboardTabs items={TYPE_TABS} active={type} onChange={setType} sticky label="Leaderboard type" />
|
||||
<LeaderboardTabs items={PERIOD_TABS} active={period} onChange={setPeriod} label="Leaderboard period" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-6 rounded-3xl border border-white/10 bg-white/[0.03] px-6 py-5 text-sm text-slate-400">
|
||||
Refreshing leaderboard...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8">
|
||||
<LeaderboardList items={items} type={type} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
78
resources/js/Pages/Profile/ProfileGallery.jsx
Normal file
78
resources/js/Pages/Profile/ProfileGallery.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileHero from '../../components/profile/ProfileHero'
|
||||
import ProfileGalleryPanel from '../../components/profile/ProfileGalleryPanel'
|
||||
|
||||
export default function ProfileGallery() {
|
||||
const { props } = usePage()
|
||||
const {
|
||||
user,
|
||||
profile,
|
||||
artworks,
|
||||
featuredArtworks,
|
||||
followerCount,
|
||||
viewerIsFollowing,
|
||||
heroBgUrl,
|
||||
leaderboardRank,
|
||||
countryName,
|
||||
isOwner,
|
||||
profileUrl,
|
||||
} = props
|
||||
|
||||
const username = user.username || user.name
|
||||
const displayName = user.name || user.username || 'Creator'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-16">
|
||||
<ProfileHero
|
||||
user={user}
|
||||
profile={profile}
|
||||
isOwner={isOwner}
|
||||
viewerIsFollowing={viewerIsFollowing}
|
||||
followerCount={followerCount}
|
||||
heroBgUrl={heroBgUrl}
|
||||
countryName={countryName}
|
||||
leaderboardRank={leaderboardRank}
|
||||
extraActions={profileUrl ? (
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-user fa-fw" />
|
||||
View Profile
|
||||
</a>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<div className="border-y border-white/10 bg-white/[0.02]">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4 px-4 py-5 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-300/80">Public Gallery</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-white md:text-3xl">
|
||||
{displayName}'s artworks
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">
|
||||
Browse published work with the same infinite-scroll gallery used across the profile experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={profileUrl || '#'}
|
||||
className="inline-flex items-center gap-2 self-start rounded-xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left fa-fw" />
|
||||
Back to profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pt-6 md:px-6">
|
||||
<ProfileGalleryPanel
|
||||
artworks={artworks}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={username}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileHero from '../../Components/Profile/ProfileHero'
|
||||
import ProfileStatsRow from '../../Components/Profile/ProfileStatsRow'
|
||||
import ProfileTabs from '../../Components/Profile/ProfileTabs'
|
||||
import TabArtworks from '../../Components/Profile/tabs/TabArtworks'
|
||||
import TabAbout from '../../Components/Profile/tabs/TabAbout'
|
||||
import TabStats from '../../Components/Profile/tabs/TabStats'
|
||||
import TabFavourites from '../../Components/Profile/tabs/TabFavourites'
|
||||
import TabCollections from '../../Components/Profile/tabs/TabCollections'
|
||||
import TabActivity from '../../Components/Profile/tabs/TabActivity'
|
||||
import TabPosts from '../../Components/Profile/tabs/TabPosts'
|
||||
import TabStories from '../../Components/Profile/tabs/TabStories'
|
||||
import ProfileHero from '../../components/profile/ProfileHero'
|
||||
import ProfileStatsRow from '../../components/profile/ProfileStatsRow'
|
||||
import ProfileTabs from '../../components/profile/ProfileTabs'
|
||||
import TabArtworks from '../../components/profile/tabs/TabArtworks'
|
||||
import TabAchievements from '../../components/profile/tabs/TabAchievements'
|
||||
import TabAbout from '../../components/profile/tabs/TabAbout'
|
||||
import TabStats from '../../components/profile/tabs/TabStats'
|
||||
import TabFavourites from '../../components/profile/tabs/TabFavourites'
|
||||
import TabCollections from '../../components/profile/tabs/TabCollections'
|
||||
import TabActivity from '../../components/profile/tabs/TabActivity'
|
||||
import TabPosts from '../../components/profile/tabs/TabPosts'
|
||||
import TabStories from '../../components/profile/tabs/TabStories'
|
||||
|
||||
const VALID_TABS = ['artworks', 'stories', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
const VALID_TABS = ['artworks', 'stories', 'achievements', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
|
||||
function getInitialTab() {
|
||||
try {
|
||||
@@ -46,9 +47,13 @@ export default function ProfileShow() {
|
||||
heroBgUrl,
|
||||
profileComments,
|
||||
creatorStories,
|
||||
achievements,
|
||||
leaderboardRank,
|
||||
countryName,
|
||||
isOwner,
|
||||
auth,
|
||||
profileUrl,
|
||||
galleryUrl,
|
||||
} = props
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab)
|
||||
@@ -83,6 +88,10 @@ export default function ProfileShow() {
|
||||
? artworks
|
||||
: (artworks?.data ?? [])
|
||||
const artworkNextCursor = artworks?.next_cursor ?? null
|
||||
const favouriteList = Array.isArray(favourites)
|
||||
? favourites
|
||||
: (favourites?.data ?? [])
|
||||
const favouriteNextCursor = favourites?.next_cursor ?? null
|
||||
|
||||
// Normalise social links (may be object keyed by platform, or array)
|
||||
const socialLinksObj = Array.isArray(socialLinks)
|
||||
@@ -100,6 +109,16 @@ export default function ProfileShow() {
|
||||
followerCount={followerCount}
|
||||
heroBgUrl={heroBgUrl}
|
||||
countryName={countryName}
|
||||
leaderboardRank={leaderboardRank}
|
||||
extraActions={galleryUrl ? (
|
||||
<a
|
||||
href={galleryUrl}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-images fa-fw" />
|
||||
View Gallery
|
||||
</a>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{/* Stats pills row */}
|
||||
@@ -146,6 +165,9 @@ export default function ProfileShow() {
|
||||
username={user.username || user.name}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'achievements' && (
|
||||
<TabAchievements achievements={achievements} />
|
||||
)}
|
||||
{activeTab === 'collections' && (
|
||||
<TabCollections collections={[]} />
|
||||
)}
|
||||
@@ -166,7 +188,7 @@ export default function ProfileShow() {
|
||||
)}
|
||||
{activeTab === 'favourites' && (
|
||||
<TabFavourites
|
||||
favourites={favourites}
|
||||
favourites={{ data: favouriteList, next_cursor: favouriteNextCursor }}
|
||||
isOwner={isOwner}
|
||||
username={user.username || user.name}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Textarea from '../../components/ui/Textarea'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Toggle from '../../components/ui/Toggle'
|
||||
import Select from '../../components/ui/Select'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
import { RadioGroup } from '../../components/ui/Radio'
|
||||
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
|
||||
@@ -183,7 +184,7 @@ export default function ProfileEdit() {
|
||||
month: fromUser.month || fromProps.month || '',
|
||||
year: fromUser.year || fromProps.year || '',
|
||||
gender: String(user?.gender || '').toLowerCase() || '',
|
||||
country: user?.country_code || '',
|
||||
country_id: user?.country_id ? String(user.country_id) : '',
|
||||
}
|
||||
})
|
||||
const [notificationForm, setNotificationForm] = useState({
|
||||
@@ -349,8 +350,12 @@ export default function ProfileEdit() {
|
||||
}
|
||||
|
||||
const countryOptions = (countries || []).map((c) => ({
|
||||
value: c.country_code || c.code || c.id || '',
|
||||
label: c.country_name || c.name || '',
|
||||
value: String(c.id || ''),
|
||||
label: c.name || '',
|
||||
iso2: c.iso2 || '',
|
||||
flagEmoji: c.flag_emoji || '',
|
||||
flagPath: c.flag_path || '',
|
||||
group: c.is_featured ? 'Featured' : 'All countries',
|
||||
}))
|
||||
|
||||
const yearOptions = useMemo(() => {
|
||||
@@ -685,7 +690,7 @@ export default function ProfileEdit() {
|
||||
body: JSON.stringify(applyCaptchaPayload({
|
||||
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
|
||||
gender: personalForm.gender || null,
|
||||
country: personalForm.country || null,
|
||||
country_id: personalForm.country_id || null,
|
||||
homepage_url: '',
|
||||
})),
|
||||
})
|
||||
@@ -1158,28 +1163,41 @@ export default function ProfileEdit() {
|
||||
/>
|
||||
|
||||
{countryOptions.length > 0 ? (
|
||||
<Select
|
||||
<NovaSelect
|
||||
label="Country"
|
||||
value={personalForm.country}
|
||||
onChange={(e) => {
|
||||
setPersonalForm((prev) => ({ ...prev, country: e.target.value }))
|
||||
value={personalForm.country_id || null}
|
||||
onChange={(value) => {
|
||||
setPersonalForm((prev) => ({ ...prev, country_id: value ? String(value) : '' }))
|
||||
clearSectionStatus('personal')
|
||||
}}
|
||||
options={countryOptions}
|
||||
placeholder="Select country"
|
||||
error={errorsBySection.personal.country?.[0]}
|
||||
placeholder="Choose country"
|
||||
clearable
|
||||
error={errorsBySection.personal.country_id?.[0] || errorsBySection.personal.country?.[0]}
|
||||
hint="Search by country name or ISO code."
|
||||
renderOption={(option) => (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{option.flagPath ? (
|
||||
<img
|
||||
src={option.flagPath}
|
||||
alt=""
|
||||
className="h-4 w-6 rounded-sm object-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : option.flagEmoji ? (
|
||||
<span>{option.flagEmoji}</span>
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.iso2 ? <span className="shrink-0 text-[11px] uppercase text-slate-500">{option.iso2}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Country"
|
||||
value={personalForm.country}
|
||||
onChange={(e) => {
|
||||
setPersonalForm((prev) => ({ ...prev, country: e.target.value }))
|
||||
clearSectionStatus('personal')
|
||||
}}
|
||||
placeholder="Country code (e.g. US, DE, TR)"
|
||||
error={errorsBySection.personal.country?.[0]}
|
||||
/>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-400">
|
||||
Country list is currently unavailable.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderCaptchaChallenge('personal')}
|
||||
|
||||
71
resources/js/components/Feed/EmbeddedArtworkCard.jsx
Normal file
71
resources/js/components/Feed/EmbeddedArtworkCard.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
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}`
|
||||
|
||||
const handleCardClick = (e) => {
|
||||
// Don't navigate when clicking the author link
|
||||
if (e.defaultPrevented) return
|
||||
window.location.href = artUrl
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
window.location.href = artUrl
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// Outer element is a div to avoid <a> inside <a> — navigation handled via onClick
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={artwork.title}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
function formatRelative(isoString) {
|
||||
const diff = Date.now() - new Date(isoString).getTime()
|
||||
@@ -133,6 +134,7 @@ export default function PostComments({ postId, isLoggedIn, isOwn = false, initia
|
||||
>
|
||||
{c.author.name || `@${c.author.username}`}
|
||||
</a>
|
||||
<LevelBadge level={c.author.level} rank={c.author.rank} compact />
|
||||
<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">
|
||||
|
||||
32
resources/js/components/achievements/AchievementBadge.jsx
Normal file
32
resources/js/components/achievements/AchievementBadge.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
|
||||
const TYPE_TONES = {
|
||||
Uploads: 'border-amber-400/40 bg-amber-500/10 text-amber-100',
|
||||
Engagement: 'border-rose-400/40 bg-rose-500/10 text-rose-100',
|
||||
Social: 'border-sky-400/40 bg-sky-500/10 text-sky-100',
|
||||
Stories: 'border-emerald-400/40 bg-emerald-500/10 text-emerald-100',
|
||||
Milestones: 'border-violet-400/40 bg-violet-500/10 text-violet-100',
|
||||
}
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function AchievementBadge({ achievement, compact = false, className = '' }) {
|
||||
const tone = TYPE_TONES[achievement?.type] || TYPE_TONES.Milestones
|
||||
const unlocked = Boolean(achievement?.unlocked)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'inline-flex items-center gap-2 rounded-full border px-2.5 py-1 font-semibold tracking-[0.08em]',
|
||||
compact ? 'text-[10px] uppercase' : 'text-[11px] uppercase',
|
||||
unlocked ? tone : 'border-white/10 bg-white/5 text-slate-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<i className={`fa-solid ${achievement?.icon || 'fa-trophy'} text-[0.9em]`} />
|
||||
<span>{achievement?.type || 'Achievement'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
54
resources/js/components/achievements/AchievementCard.jsx
Normal file
54
resources/js/components/achievements/AchievementCard.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import AchievementBadge from './AchievementBadge'
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function AchievementCard({ achievement, className = '' }) {
|
||||
const unlocked = Boolean(achievement?.unlocked)
|
||||
const progress = Number(achievement?.progress || 0)
|
||||
const target = Number(achievement?.condition_value || 0)
|
||||
const percent = Math.max(0, Math.min(100, Number(achievement?.progress_percent || 0)))
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cx(
|
||||
'rounded-2xl border p-4 shadow-lg transition',
|
||||
unlocked
|
||||
? 'border-emerald-400/25 bg-[linear-gradient(180deg,rgba(16,185,129,0.08),rgba(15,23,42,0.72))]'
|
||||
: 'border-white/10 bg-white/[0.04]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<AchievementBadge achievement={achievement} compact />
|
||||
<h3 className="mt-3 text-base font-semibold text-white">{achievement?.name}</h3>
|
||||
<p className="mt-1 text-sm text-slate-300">{achievement?.description}</p>
|
||||
</div>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-lg text-white/90">
|
||||
<i className={`fa-solid ${achievement?.icon || 'fa-trophy'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<span>{achievement?.xp_reward || 0} XP reward</span>
|
||||
{unlocked ? <span>Unlocked</span> : <span>{progress} / {target}</span>}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10 ring-1 ring-white/10">
|
||||
<div
|
||||
className={cx('h-full rounded-full transition-[width] duration-500', unlocked ? 'bg-emerald-400' : 'bg-sky-400')}
|
||||
style={{ width: `${unlocked ? 100 : percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{achievement?.unlocked_at ? (
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Unlocked {new Date(achievement.unlocked_at).toLocaleDateString()}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
44
resources/js/components/achievements/AchievementsList.jsx
Normal file
44
resources/js/components/achievements/AchievementsList.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import AchievementCard from './AchievementCard'
|
||||
|
||||
export default function AchievementsList({ unlocked = [], locked = [], limitLocked }) {
|
||||
const visibleLocked = typeof limitLocked === 'number' ? locked.slice(0, limitLocked) : locked
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Unlocked</h2>
|
||||
<span className="text-xs text-slate-500">{unlocked.length} earned</span>
|
||||
</div>
|
||||
|
||||
{unlocked.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-8 text-sm text-slate-400">
|
||||
No achievements unlocked yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{unlocked.map((achievement) => (
|
||||
<AchievementCard key={achievement.id} achievement={achievement} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">In Progress</h2>
|
||||
<span className="text-xs text-slate-500">{locked.length} still locked</span>
|
||||
</div>
|
||||
|
||||
{visibleLocked.length === 0 ? null : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleLocked.map((achievement) => (
|
||||
<AchievementCard key={achievement.id} achievement={achievement} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -192,15 +192,25 @@ function ReportModal({ open, onClose, onSubmit, submitting }) {
|
||||
|
||||
export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) {
|
||||
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
|
||||
const [bookmarked, setBookmarked] = useState(Boolean(artwork?.viewer?.is_bookmarked))
|
||||
const [bookmarkCount, setBookmarkCount] = useState(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const isLoggedIn = artwork?.viewer != null
|
||||
const isLoggedIn = Boolean(artwork?.viewer?.is_authenticated)
|
||||
useEffect(() => {
|
||||
setFavorited(Boolean(artwork?.viewer?.is_favorited))
|
||||
}, [artwork?.id, artwork?.viewer?.is_favorited])
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarked(Boolean(artwork?.viewer?.is_bookmarked))
|
||||
}, [artwork?.id, artwork?.viewer?.is_bookmarked])
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarkCount(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
|
||||
}, [artwork?.id, artwork?.stats?.bookmarks, stats?.bookmarks])
|
||||
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
@@ -249,6 +259,11 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = !favorited
|
||||
setFavorited(nextState)
|
||||
try {
|
||||
@@ -257,6 +272,26 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
} catch { setFavorited(!nextState) }
|
||||
}
|
||||
|
||||
const onToggleBookmark = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = !bookmarked
|
||||
setBookmarked(nextState)
|
||||
setBookmarkCount((current) => Math.max(0, current + (nextState ? 1 : -1)))
|
||||
|
||||
try {
|
||||
const payload = await postInteraction(`/api/artworks/${artwork.id}/bookmark`, { state: nextState })
|
||||
setBookmarked(Boolean(payload?.is_bookmarked))
|
||||
setBookmarkCount(Number(payload?.stats?.bookmarks ?? 0))
|
||||
} catch {
|
||||
setBookmarked(!nextState)
|
||||
setBookmarkCount((current) => Math.max(0, current + (nextState ? -1 : 1)))
|
||||
}
|
||||
}
|
||||
|
||||
const openReport = () => {
|
||||
if (reported) return
|
||||
setReportOpen(true)
|
||||
@@ -274,6 +309,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
}
|
||||
|
||||
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
|
||||
const savedCount = formatCount(bookmarkCount)
|
||||
const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0)
|
||||
|
||||
return (
|
||||
@@ -296,6 +332,21 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
|
||||
onClick={onToggleBookmark}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
bookmarked
|
||||
? 'border-amber-400/35 bg-amber-400/14 text-amber-200 shadow-lg shadow-amber-500/10 hover:bg-amber-400/18'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Views stat pill */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70">
|
||||
<CloudDownIcon />
|
||||
@@ -353,6 +404,21 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
|
||||
onClick={onToggleBookmark}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
bookmarked
|
||||
? 'border-amber-400/35 bg-amber-400/14 text-amber-200'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" isLoggedIn={isLoggedIn} />
|
||||
|
||||
|
||||
@@ -1,67 +1,16 @@
|
||||
import React, { useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [pendingFollowState, setPendingFollowState] = useState(null)
|
||||
const user = artwork?.user || {}
|
||||
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
|
||||
const authorName = user.name || user.username || 'Artist'
|
||||
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
|
||||
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const persistFollowState = async (nextState) => {
|
||||
setFollowing(nextState)
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ state: nextState }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Follow failed')
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.followers_count === 'number') {
|
||||
setFollowersCount(payload.followers_count)
|
||||
}
|
||||
setFollowing(Boolean(payload?.is_following))
|
||||
} catch {
|
||||
setFollowing(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
const nextState = !following
|
||||
if (!nextState) {
|
||||
setPendingFollowState(nextState)
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await persistFollowState(nextState)
|
||||
}
|
||||
|
||||
const onConfirmUnfollow = async () => {
|
||||
if (pendingFollowState === null) return
|
||||
setConfirmOpen(false)
|
||||
await persistFollowState(pendingFollowState)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
const onCloseConfirm = () => {
|
||||
setConfirmOpen(false)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,7 +38,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className={`mt-4 grid gap-3 ${isOwnArtwork ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
|
||||
@@ -99,40 +48,22 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${following ? 'border border-nova-600 text-white hover:bg-nova-800' : 'bg-accent text-deep hover:brightness-110'}`}
|
||||
onClick={onToggleFollow}
|
||||
>
|
||||
{following ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z" />
|
||||
</svg>
|
||||
Following
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!isOwnArtwork ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={following}
|
||||
initialCount={followersCount}
|
||||
showCount={false}
|
||||
className="min-h-11"
|
||||
sizeClassName="px-3 py-2 text-sm"
|
||||
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setFollowersCount(nextFollowersCount)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={`You will stop seeing updates from @${user.username || user.name || 'this creator'} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={onConfirmUnfollow}
|
||||
onClose={onCloseConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
@@ -17,6 +18,39 @@ function formatCount(value) {
|
||||
return numberFormatter.format(numeric)
|
||||
}
|
||||
|
||||
function formatRelativeTime(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
|
||||
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
|
||||
|
||||
const diffHours = Math.round(diffSeconds / 3600)
|
||||
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
|
||||
|
||||
const diffDays = Math.round(diffSeconds / 86400)
|
||||
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
|
||||
|
||||
const diffWeeks = Math.round(diffSeconds / 604800)
|
||||
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
|
||||
|
||||
const diffMonths = Math.round(diffSeconds / 2629800)
|
||||
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
|
||||
|
||||
const diffYears = Math.round(diffSeconds / 31557600)
|
||||
return rtf.format(diffYears, 'year')
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
@@ -137,6 +171,20 @@ function ActionButton({ label, children, onClick }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BadgePill({ className = '', iconClass = '', children }) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] backdrop-blur-sm ring-1 shadow-[0_8px_24px_rgba(2,6,23,0.28)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{iconClass ? <i className={iconClass} aria-hidden="true" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkCard({
|
||||
artwork,
|
||||
variant = 'default',
|
||||
@@ -159,9 +207,10 @@ export default function ArtworkCard({
|
||||
fetchPriority,
|
||||
onLike,
|
||||
showActions = true,
|
||||
metricBadge = null,
|
||||
}) {
|
||||
const item = artwork || {}
|
||||
const rawAuthor = item.author
|
||||
const rawAuthor = item.author || item.creator
|
||||
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
|
||||
const author = decodeHtml(
|
||||
(typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|
||||
@@ -170,6 +219,8 @@ export default function ArtworkCard({
|
||||
|| 'Skinbase Artist'
|
||||
)
|
||||
const username = rawAuthor?.username || item.author_username || item.username || null
|
||||
const authorLevel = Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
|
||||
const authorRank = rawAuthor?.rank || item.author_rank || item.creator?.rank || ''
|
||||
const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK
|
||||
const avatar = rawAuthor?.avatar_url || rawAuthor?.avatar || item.avatar || item.author_avatar || item.avatar_url || AVATAR_FALLBACK
|
||||
const likes = item.likes ?? item.favourites ?? 0
|
||||
@@ -194,6 +245,11 @@ export default function ArtworkCard({
|
||||
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
|
||||
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
|
||||
const authorHref = username ? `/@${username}` : null
|
||||
const resolvedMetricBadge = metricBadge || item.metric_badge || null
|
||||
const relativePublishedAt = useMemo(
|
||||
() => formatRelativeTime(item.published_at || item.publishedAt || null),
|
||||
[item.published_at, item.publishedAt]
|
||||
)
|
||||
const initialLiked = Boolean(item.viewer?.is_liked)
|
||||
const [liked, setLiked] = useState(initialLiked)
|
||||
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
|
||||
@@ -294,7 +350,7 @@ export default function ArtworkCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white/90">{title}</p>
|
||||
{showAuthor && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-400">
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
||||
{authorHref ? (
|
||||
<span>
|
||||
by {author} <span className="text-slate-500">@{username}</span>
|
||||
@@ -302,7 +358,8 @@ export default function ArtworkCard({
|
||||
) : (
|
||||
<span>by {author}</span>
|
||||
)}
|
||||
</p>
|
||||
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact /> : null}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 truncate text-[10px] uppercase tracking-wider text-slate-600">
|
||||
{contentType || 'Artwork'}
|
||||
@@ -349,8 +406,29 @@ export default function ArtworkCard({
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
|
||||
|
||||
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3">
|
||||
<div>
|
||||
{resolvedMetricBadge?.label ? (
|
||||
<BadgePill className={resolvedMetricBadge.className || 'bg-emerald-500/14 text-emerald-200 ring-emerald-400/30'} iconClass={resolvedMetricBadge.iconClass}>
|
||||
{resolvedMetricBadge.label}
|
||||
</BadgePill>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{relativePublishedAt ? (
|
||||
<BadgePill className="bg-black/45 text-white/75 ring-white/12" iconClass="fa-regular fa-clock text-[10px]">
|
||||
{relativePublishedAt}
|
||||
</BadgePill>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showActions && (
|
||||
<div className="absolute right-3 top-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100">
|
||||
<div className={cx(
|
||||
'absolute right-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
|
||||
relativePublishedAt ? 'top-12' : 'top-3'
|
||||
)}>
|
||||
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
|
||||
<HeartIcon className={cx('h-4 w-4 transition-transform duration-200', liked ? 'fill-current text-rose-300' : '', likeBusy ? 'scale-90' : '')} />
|
||||
</ActionButton>
|
||||
@@ -384,9 +462,12 @@ export default function ArtworkCard({
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white/90">
|
||||
{author}
|
||||
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
|
||||
{author}
|
||||
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
|
||||
</span>
|
||||
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
|
||||
</span>
|
||||
{showStats && metadataLine && (
|
||||
<span className="mt-0.5 block truncate text-[11px] text-white/70">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import CommentForm from '../comments/CommentForm'
|
||||
import ReactionBar from '../comments/ReactionBar'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import { isFlood } from '../../utils/emojiFlood'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -130,6 +131,7 @@ function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, dept
|
||||
) : (
|
||||
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={reply.created_at}
|
||||
@@ -286,6 +288,7 @@ function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
|
||||
) : (
|
||||
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
@@ -24,16 +24,12 @@ function toCard(item) {
|
||||
export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [pendingFollowState, setPendingFollowState] = useState(null)
|
||||
|
||||
const user = artwork?.user || {}
|
||||
const isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
|
||||
const authorName = user.name || user.username || 'Artist'
|
||||
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
|
||||
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const creatorItems = useMemo(() => {
|
||||
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
|
||||
@@ -46,53 +42,6 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
return source.slice(0, 12).map(toCard)
|
||||
}, [related, authorName, artwork?.canonical_url])
|
||||
|
||||
const persistFollowState = async (nextState) => {
|
||||
setFollowing(nextState)
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ state: nextState }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Follow failed')
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.followers_count === 'number') {
|
||||
setFollowersCount(payload.followers_count)
|
||||
}
|
||||
setFollowing(Boolean(payload?.is_following))
|
||||
} catch {
|
||||
setFollowing(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
const nextState = !following
|
||||
if (!nextState) {
|
||||
setPendingFollowState(nextState)
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await persistFollowState(nextState)
|
||||
}
|
||||
|
||||
const onConfirmUnfollow = async () => {
|
||||
if (pendingFollowState === null) return
|
||||
setConfirmOpen(false)
|
||||
await persistFollowState(pendingFollowState)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
const onCloseConfirm = () => {
|
||||
setConfirmOpen(false)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
@@ -131,22 +80,19 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
onClick={onToggleFollow}
|
||||
className={[
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
following
|
||||
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
|
||||
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
{!isOwnArtwork ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={following}
|
||||
initialCount={followersCount}
|
||||
showCount={false}
|
||||
className="flex-1"
|
||||
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setFollowersCount(nextFollowersCount)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,16 +135,6 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={`You will stop seeing updates from @${user.username || authorName} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={onConfirmUnfollow}
|
||||
onClose={onCloseConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,46 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ActivityArtworkPreview({ artwork }) {
|
||||
if (!artwork?.url || !artwork?.thumb) return null
|
||||
export default function ActivityArtworkPreview({ artwork, story }) {
|
||||
if (artwork?.url && artwork?.thumb) {
|
||||
return (
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
|
||||
>
|
||||
<div className="aspect-[6/5] overflow-hidden bg-black/20">
|
||||
<img
|
||||
src={artwork.thumb}
|
||||
alt={artwork.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/[0.06] px-3 py-2">
|
||||
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (!story?.url || !story?.cover_url) return null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={artwork.url}
|
||||
href={story.url}
|
||||
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
|
||||
>
|
||||
<div className="aspect-[6/5] overflow-hidden bg-black/20">
|
||||
<img
|
||||
src={artwork.thumb}
|
||||
alt={artwork.title || 'Artwork'}
|
||||
src={story.cover_url}
|
||||
alt={story.title || 'Story'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/[0.06] px-3 py-2">
|
||||
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
|
||||
<p className="truncate text-[11px] font-medium text-white/65">{story.title || 'Story'}</p>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
|
||||
@@ -6,11 +6,65 @@ import ActivityReactions from './ActivityReactions'
|
||||
function ActivityHeadline({ activity }) {
|
||||
const artworkLink = activity?.artwork?.url
|
||||
const artworkTitle = activity?.artwork?.title || 'an artwork'
|
||||
const storyLink = activity?.story?.url
|
||||
const storyTitle = activity?.story?.title || 'a story'
|
||||
const mentionedUser = activity?.mentioned_user
|
||||
const reaction = activity?.reaction
|
||||
const commentAuthor = activity?.comment?.author
|
||||
const targetUser = activity?.target_user
|
||||
|
||||
switch (activity?.type) {
|
||||
case 'upload':
|
||||
if (storyLink) {
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">published </span>
|
||||
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">published </span>
|
||||
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'favorite':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">favorited </span>
|
||||
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'follow':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">followed </span>
|
||||
{targetUser?.profile_url ? <a href={targetUser.profile_url} className="text-sky-300 hover:text-sky-200">@{targetUser.username || targetUser.name}</a> : <span className="text-white">another creator</span>}
|
||||
</p>
|
||||
)
|
||||
case 'award':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">awarded </span>
|
||||
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'story_like':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">liked </span>
|
||||
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'story_comment':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
<span className="font-medium text-white">commented on </span>
|
||||
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
|
||||
</p>
|
||||
)
|
||||
case 'comment':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-white/70">
|
||||
@@ -80,7 +134,7 @@ export default function ActivityCard({ activity, isLoggedIn = false }) {
|
||||
</div>
|
||||
|
||||
<div className="sm:ml-auto">
|
||||
<ActivityArtworkPreview artwork={activity.artwork} />
|
||||
<ActivityArtworkPreview artwork={activity.artwork} story={activity.story} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -3,8 +3,13 @@ import ReactionBar from '../comments/ReactionBar'
|
||||
|
||||
export default function ActivityReactions({ activity, isLoggedIn = false }) {
|
||||
const commentId = activity?.comment?.id || null
|
||||
const commentUrl = activity?.comment?.url || activity?.artwork?.url || '#'
|
||||
const commentUrl = activity?.comment?.url || null
|
||||
const artworkUrl = activity?.artwork?.url || null
|
||||
const storyUrl = activity?.story?.url || null
|
||||
|
||||
if (!commentId && !artworkUrl && !storyUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
@@ -17,13 +22,15 @@ export default function ActivityReactions({ activity, isLoggedIn = false }) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<a
|
||||
href={commentUrl}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
>
|
||||
<i className="fa-regular fa-comment-dots" />
|
||||
Reply
|
||||
</a>
|
||||
{commentUrl ? (
|
||||
<a
|
||||
href={commentUrl}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
|
||||
>
|
||||
<i className="fa-regular fa-comment-dots" />
|
||||
Reply
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{artworkUrl ? (
|
||||
<a
|
||||
@@ -34,6 +41,16 @@ export default function ActivityReactions({ activity, isLoggedIn = false }) {
|
||||
View artwork
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{storyUrl ? (
|
||||
<a
|
||||
href={storyUrl}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<i className="fa-regular fa-newspaper" />
|
||||
Read story
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { EditorContent, Extension, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Suggestion from '@tiptap/suggestion';
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
import tippy from 'tippy.js';
|
||||
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
|
||||
import TurnstileField from '../security/TurnstileField';
|
||||
import Select from '../ui/Select';
|
||||
|
||||
type StoryType = {
|
||||
slug: string;
|
||||
@@ -62,6 +68,28 @@ type Props = {
|
||||
csrfToken: string;
|
||||
};
|
||||
|
||||
const EMPTY_DOC = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }],
|
||||
};
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const CODE_BLOCK_LANGUAGES = [
|
||||
{ value: 'bash', label: 'Bash / Shell' },
|
||||
{ value: 'plaintext', label: 'Plain text' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'xml', label: 'XML / SVG' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
];
|
||||
|
||||
const ArtworkBlock = Node.create({
|
||||
name: 'artworkEmbed',
|
||||
group: 'block',
|
||||
@@ -230,11 +258,14 @@ const DownloadAssetBlock = Node.create({
|
||||
|
||||
function createSlashCommandExtension(insert: {
|
||||
image: () => void;
|
||||
uploadImage: () => void;
|
||||
artwork: () => void;
|
||||
code: () => void;
|
||||
quote: () => void;
|
||||
divider: () => void;
|
||||
gallery: () => void;
|
||||
video: () => void;
|
||||
download: () => void;
|
||||
}) {
|
||||
return Extension.create({
|
||||
name: 'slashCommands',
|
||||
@@ -246,22 +277,28 @@ function createSlashCommandExtension(insert: {
|
||||
startOfLine: true,
|
||||
items: ({ query }: { query: string }) => {
|
||||
const all = [
|
||||
{ title: 'Upload Image', key: 'uploadImage' },
|
||||
{ title: 'Image', key: 'image' },
|
||||
{ title: 'Artwork', key: 'artwork' },
|
||||
{ title: 'Code', key: 'code' },
|
||||
{ title: 'Quote', key: 'quote' },
|
||||
{ title: 'Divider', key: 'divider' },
|
||||
{ title: 'Gallery', key: 'gallery' },
|
||||
{ title: 'Video', key: 'video' },
|
||||
{ title: 'Download', key: 'download' },
|
||||
];
|
||||
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
|
||||
},
|
||||
command: ({ props }: { editor: any; props: { key: string } }) => {
|
||||
if (props.key === 'uploadImage') insert.uploadImage();
|
||||
if (props.key === 'image') insert.image();
|
||||
if (props.key === 'artwork') insert.artwork();
|
||||
if (props.key === 'code') insert.code();
|
||||
if (props.key === 'quote') insert.quote();
|
||||
if (props.key === 'divider') insert.divider();
|
||||
if (props.key === 'gallery') insert.gallery();
|
||||
if (props.key === 'video') insert.video();
|
||||
if (props.key === 'download') insert.download();
|
||||
},
|
||||
render: () => {
|
||||
let popup: any;
|
||||
@@ -359,22 +396,37 @@ function createSlashCommandExtension(insert: {
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string): Promise<T> {
|
||||
async function botHeaders(extra: Record<string, string> = {}, captcha: { token?: string } = {}) {
|
||||
const fingerprint = await buildBotFingerprint();
|
||||
|
||||
return {
|
||||
...extra,
|
||||
'X-Bot-Fingerprint': fingerprint,
|
||||
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string, captcha: { token?: string } = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
}, captcha),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
const error = new Error((payload as any)?.message || `Request failed: ${response.status}`) as Error & { status?: number; payload?: unknown };
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) {
|
||||
@@ -398,7 +450,26 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
const [showLivePreview, setShowLivePreview] = useState(false);
|
||||
const [livePreviewHtml, setLivePreviewHtml] = useState('');
|
||||
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||
const [generalError, setGeneralError] = useState('');
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const [readMinutes, setReadMinutes] = useState(1);
|
||||
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [captchaState, setCaptchaState] = useState({
|
||||
required: false,
|
||||
token: '',
|
||||
message: '',
|
||||
nonce: 0,
|
||||
provider: 'turnstile',
|
||||
siteKey: '',
|
||||
inputName: 'cf-turnstile-response',
|
||||
scriptUrl: '',
|
||||
});
|
||||
const lastSavedRef = useRef('');
|
||||
const editorRef = useRef<any>(null);
|
||||
const bodyImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const coverImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
|
||||
window.dispatchEvent(new CustomEvent('story-editor:saved', {
|
||||
@@ -410,6 +481,58 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const resetCaptchaState = useCallback(() => {
|
||||
setCaptchaState((prev) => ({
|
||||
...prev,
|
||||
required: false,
|
||||
token: '',
|
||||
message: '',
|
||||
nonce: prev.nonce + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const captureCaptchaRequirement = useCallback((payload: any = {}) => {
|
||||
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha);
|
||||
if (!requiresCaptcha) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextCaptcha = payload?.captcha || {};
|
||||
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.';
|
||||
|
||||
setCaptchaState((prev) => ({
|
||||
required: true,
|
||||
token: '',
|
||||
message,
|
||||
nonce: prev.nonce + 1,
|
||||
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || 'turnstile',
|
||||
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || '',
|
||||
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || 'cf-turnstile-response',
|
||||
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || '',
|
||||
}));
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const applyFailure = useCallback((error: any, fallback: string) => {
|
||||
const payload = error?.payload || {};
|
||||
const nextErrors = payload?.errors && typeof payload.errors === 'object' ? payload.errors : {};
|
||||
setFieldErrors(nextErrors);
|
||||
const requiresCaptcha = captureCaptchaRequirement(payload);
|
||||
const message = nextErrors?.captcha?.[0]
|
||||
|| nextErrors?.title?.[0]
|
||||
|| nextErrors?.content?.[0]
|
||||
|| payload?.message
|
||||
|| fallback;
|
||||
setGeneralError(message);
|
||||
setSaveStatus(requiresCaptcha ? 'Captcha required' : message);
|
||||
}, [captureCaptchaRequirement]);
|
||||
|
||||
const clearFeedback = useCallback(() => {
|
||||
setGeneralError('');
|
||||
setFieldErrors({});
|
||||
}, []);
|
||||
|
||||
const openLinkPrompt = useCallback((editor: any) => {
|
||||
const prev = editor.getAttributes('link').href;
|
||||
const url = window.prompt('Link URL', prev || 'https://');
|
||||
@@ -439,66 +562,111 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
|
||||
const response = await fetch(endpoints.uploadImage, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: await botHeaders({
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
}, captchaState),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
setFieldErrors(payload?.errors && typeof payload.errors === 'object' ? payload.errors : {});
|
||||
captureCaptchaRequirement(payload);
|
||||
setGeneralError(payload?.errors?.captcha?.[0] || payload?.message || 'Image upload failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
clearFeedback();
|
||||
if (captchaState.required && captchaState.token) {
|
||||
resetCaptchaState();
|
||||
}
|
||||
|
||||
const data = payload;
|
||||
return data.medium_url || data.original_url || data.thumbnail_url || null;
|
||||
}, [endpoints.uploadImage, csrfToken]);
|
||||
}, [captchaState, captureCaptchaRequirement, clearFeedback, endpoints.uploadImage, csrfToken, resetCaptchaState]);
|
||||
|
||||
const applyCodeBlockLanguage = useCallback((language: string) => {
|
||||
const nextLanguage = (language || 'plaintext').trim() || 'plaintext';
|
||||
setCodeBlockLanguage(nextLanguage);
|
||||
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor || !currentEditor.isActive('codeBlock')) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditor.chain().focus().updateAttributes('codeBlock', { language: nextLanguage }).run();
|
||||
}, []);
|
||||
|
||||
const toggleCodeBlockWithLanguage = useCallback(() => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
|
||||
if (currentEditor.isActive('codeBlock')) {
|
||||
currentEditor.chain().focus().toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditor.chain().focus().setCodeBlock({ language: codeBlockLanguage }).run();
|
||||
}, [codeBlockLanguage]);
|
||||
|
||||
const insertActions = useMemo(() => ({
|
||||
image: () => {
|
||||
const currentEditor = editorRef.current;
|
||||
const url = window.prompt('Image URL', 'https://');
|
||||
if (!url || !editor) return;
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
if (!url || !currentEditor) return;
|
||||
currentEditor.chain().focus().setImage({ src: url }).run();
|
||||
},
|
||||
uploadImage: () => bodyImageInputRef.current?.click(),
|
||||
artwork: () => setArtworkModalOpen(true),
|
||||
code: () => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
toggleCodeBlockWithLanguage();
|
||||
},
|
||||
quote: () => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().toggleBlockquote().run();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.chain().focus().toggleBlockquote().run();
|
||||
},
|
||||
divider: () => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().setHorizontalRule().run();
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
currentEditor.chain().focus().setHorizontalRule().run();
|
||||
},
|
||||
gallery: () => {
|
||||
if (!editor) return;
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
const raw = window.prompt('Gallery image URLs (comma separated)', '');
|
||||
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
|
||||
editor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
|
||||
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
|
||||
},
|
||||
video: () => {
|
||||
if (!editor) return;
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
|
||||
if (!src) return;
|
||||
editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
|
||||
currentEditor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
|
||||
},
|
||||
download: () => {
|
||||
if (!editor) return;
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor) return;
|
||||
const url = window.prompt('Download URL', 'https://');
|
||||
if (!url) return;
|
||||
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
|
||||
editor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
|
||||
currentEditor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
|
||||
},
|
||||
}), []);
|
||||
}), [toggleCodeBlockWithLanguage]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Underline,
|
||||
Image,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
@@ -517,10 +685,10 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
DownloadAssetBlock,
|
||||
createSlashCommandExtension(insertActions),
|
||||
],
|
||||
content: initialStory.content || { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
content: initialStory.content || EMPTY_DOC,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'tiptap prose prose-invert max-w-none min-h-[26rem] rounded-xl border border-gray-700 bg-gray-900/80 px-6 py-5 text-gray-200 focus:outline-none',
|
||||
class: 'tiptap prose prose-invert prose-headings:tracking-tight prose-p:text-[1.04rem] prose-p:leading-8 prose-p:text-stone-200 prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-400 prose-blockquote:text-stone-300 prose-code:text-sky-200 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-stone-200 focus:outline-none',
|
||||
},
|
||||
handleDrop: (_view, event) => {
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
@@ -559,11 +727,17 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
},
|
||||
});
|
||||
|
||||
editorRef.current = editor;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const updatePreview = () => {
|
||||
setLivePreviewHtml(editor.getHTML());
|
||||
const text = editor.getText().replace(/\s+/g, ' ').trim();
|
||||
const words = text === '' ? 0 : text.split(' ').length;
|
||||
setWordCount(words);
|
||||
setReadMinutes(Math.max(1, Math.ceil(words / 200)));
|
||||
};
|
||||
|
||||
updatePreview();
|
||||
@@ -574,6 +748,30 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const syncCodeBlockLanguage = () => {
|
||||
if (!editor.isActive('codeBlock')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLanguage = String(editor.getAttributes('codeBlock').language || '').trim();
|
||||
if (nextLanguage !== '') {
|
||||
setCodeBlockLanguage(nextLanguage);
|
||||
}
|
||||
};
|
||||
|
||||
syncCodeBlockLanguage();
|
||||
editor.on('selectionUpdate', syncCodeBlockLanguage);
|
||||
editor.on('update', syncCodeBlockLanguage);
|
||||
|
||||
return () => {
|
||||
editor.off('selectionUpdate', syncCodeBlockLanguage);
|
||||
editor.off('update', syncCodeBlockLanguage);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artworkModalOpen) return;
|
||||
void fetchArtworks(artworkQuery);
|
||||
@@ -620,13 +818,17 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
og_image: ogImage || coverImage,
|
||||
status,
|
||||
scheduled_for: scheduledFor || null,
|
||||
content: editor?.getJSON() || { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
content: editor?.getJSON() || EMPTY_DOC,
|
||||
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const timer = window.setInterval(async () => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = payload();
|
||||
const snapshot = JSON.stringify(body);
|
||||
if (snapshot === lastSavedRef.current) {
|
||||
@@ -634,21 +836,37 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
}
|
||||
|
||||
try {
|
||||
clearFeedback();
|
||||
setSaveStatus('Saving...');
|
||||
const data = await requestJson<{ story_id?: number; message?: string }>(endpoints.autosave, 'POST', body, csrfToken);
|
||||
const data = await requestJson<{ story_id?: number; message?: string; edit_url?: string }>(
|
||||
endpoints.autosave,
|
||||
'POST',
|
||||
captchaState.required && captchaState.inputName ? {
|
||||
...body,
|
||||
[captchaState.inputName]: captchaState.token || '',
|
||||
} : body,
|
||||
csrfToken,
|
||||
captchaState,
|
||||
);
|
||||
if (data.story_id && !storyId) {
|
||||
setStoryId(data.story_id);
|
||||
}
|
||||
if (data.edit_url && window.location.pathname.endsWith('/create')) {
|
||||
window.history.replaceState({}, '', data.edit_url);
|
||||
}
|
||||
lastSavedRef.current = snapshot;
|
||||
setSaveStatus(data.message || 'Saved just now');
|
||||
if (captchaState.required && captchaState.token) {
|
||||
resetCaptchaState();
|
||||
}
|
||||
emitSaveEvent('autosave', data.story_id || storyId);
|
||||
} catch {
|
||||
setSaveStatus('Autosave failed');
|
||||
} catch (error) {
|
||||
applyFailure(error, 'Autosave failed');
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [editor, payload, endpoints.autosave, csrfToken, storyId, emitSaveEvent]);
|
||||
}, [applyFailure, captchaState, clearFeedback, csrfToken, editor, emitSaveEvent, endpoints.autosave, isSubmitting, payload, resetCaptchaState, storyId]);
|
||||
|
||||
const persistStory = async (submitAction: 'save_draft' | 'submit_review' | 'publish_now' | 'schedule_publish') => {
|
||||
const body = {
|
||||
@@ -659,20 +877,81 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
};
|
||||
|
||||
try {
|
||||
clearFeedback();
|
||||
setIsSubmitting(true);
|
||||
setSaveStatus('Saving...');
|
||||
const endpoint = storyId ? endpoints.update : endpoints.create;
|
||||
const method = storyId ? 'PUT' : 'POST';
|
||||
const data = await requestJson<{ story_id: number; message?: string }>(endpoint, method, body, csrfToken);
|
||||
const data = await requestJson<{ story_id: number; message?: string; status?: string; edit_url?: string; public_url?: string }>(endpoint, method, captchaState.required && captchaState.inputName ? {
|
||||
...body,
|
||||
[captchaState.inputName]: captchaState.token || '',
|
||||
} : body, csrfToken, captchaState);
|
||||
if (data.story_id) {
|
||||
setStoryId(data.story_id);
|
||||
}
|
||||
if (data.edit_url && window.location.pathname.endsWith('/create')) {
|
||||
window.history.replaceState({}, '', data.edit_url);
|
||||
}
|
||||
lastSavedRef.current = JSON.stringify(payload());
|
||||
setSaveStatus(data.message || 'Saved just now');
|
||||
if (captchaState.required && captchaState.token) {
|
||||
resetCaptchaState();
|
||||
}
|
||||
emitSaveEvent('manual', data.story_id || storyId);
|
||||
} catch {
|
||||
setSaveStatus('Save failed');
|
||||
if (submitAction === 'publish_now' && data.public_url) {
|
||||
window.location.assign(data.public_url);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
applyFailure(error, submitAction === 'publish_now' ? 'Publish failed' : 'Save failed');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBodyImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setSaveStatus('Uploading image...');
|
||||
const uploaded = await uploadImageFile(file);
|
||||
if (!uploaded || !editor) {
|
||||
setSaveStatus('Image upload failed');
|
||||
return;
|
||||
}
|
||||
|
||||
editor.chain().focus().setImage({ src: uploaded }).run();
|
||||
setSaveStatus('Image uploaded');
|
||||
};
|
||||
|
||||
const handleCoverImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setSaveStatus('Uploading cover...');
|
||||
const uploaded = await uploadImageFile(file);
|
||||
if (!uploaded) {
|
||||
setSaveStatus('Cover upload failed');
|
||||
return;
|
||||
}
|
||||
|
||||
setCoverImage(uploaded);
|
||||
setSaveStatus('Cover uploaded');
|
||||
};
|
||||
|
||||
const readinessChecks = useMemo(() => ([
|
||||
{ label: 'Title', ok: title.trim().length > 0, hint: 'Give the story a clear headline.' },
|
||||
{ label: 'Body', ok: wordCount >= 50, hint: 'Aim for at least 50 words before publishing.' },
|
||||
{ label: 'Story type', ok: storyType.trim().length > 0, hint: 'Choose the format that fits the post.' },
|
||||
]), [storyType, title, wordCount]);
|
||||
|
||||
const titleError = fieldErrors?.title?.[0] || '';
|
||||
const contentError = fieldErrors?.content?.[0] || '';
|
||||
const excerptError = fieldErrors?.excerpt?.[0] || '';
|
||||
const tagsError = fieldErrors?.tags_csv?.[0] || '';
|
||||
|
||||
const insertArtwork = (item: Artwork) => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().insertContent({
|
||||
@@ -689,86 +968,260 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Title"
|
||||
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-3 text-2xl font-semibold text-gray-100"
|
||||
/>
|
||||
<div className="sticky top-16 z-30 overflow-hidden rounded-[1.5rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,rgba(12,18,28,0.96),rgba(10,14,22,0.92))] p-4 shadow-[0_20px_70px_rgba(3,7,18,0.26)] backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.24em] text-white/45">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-[11px] font-semibold text-white/70">{mode === 'create' ? 'New story' : 'Editing draft'}</span>
|
||||
<span>{wordCount.toLocaleString()} words</span>
|
||||
<span>{readMinutes} min read</span>
|
||||
<span>{saveStatus}</span>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-white/62">Write in the main column, keep the sidebar for story settings, and only surface captcha when protection actually asks for it.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input value={excerpt} onChange={(event) => setExcerpt(event.target.value)} placeholder="Excerpt" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
|
||||
{storyTypes.map((type) => (
|
||||
<option key={type.slug} value={type.slug}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="Tags (comma separated)" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending_review">Pending Review</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="Cover image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<input value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} placeholder="Meta description" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">Insert block</button>
|
||||
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">{showLivePreview ? 'Hide preview' : 'Preview'}</button>
|
||||
<button type="button" onClick={() => persistStory('save_draft')} disabled={isSubmitting} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white transition hover:bg-white/[0.09] disabled:opacity-60">Save draft</button>
|
||||
<button type="button" onClick={() => persistStory('submit_review')} disabled={isSubmitting} className="rounded-xl border border-amber-400/30 bg-amber-400/12 px-3 py-2 text-sm text-amber-100 transition hover:bg-amber-400/20 disabled:opacity-60">Submit review</button>
|
||||
<button type="button" onClick={() => persistStory('publish_now')} disabled={isSubmitting} className="rounded-xl border border-emerald-400/30 bg-emerald-400/14 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-400/22 disabled:opacity-60">Publish now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">+ Insert</button>
|
||||
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">{showLivePreview ? 'Hide Preview' : 'Live Preview'}</button>
|
||||
<button type="button" onClick={() => persistStory('save_draft')} className="rounded-lg border border-gray-600 bg-gray-700/40 px-3 py-1 text-xs text-gray-200">Save Draft</button>
|
||||
<button type="button" onClick={() => persistStory('submit_review')} className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">Submit for Review</button>
|
||||
<button type="button" onClick={() => persistStory('publish_now')} className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-200">Publish Now</button>
|
||||
<button type="button" onClick={() => persistStory('schedule_publish')} className="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">Schedule Publish</button>
|
||||
<span className="ml-auto text-xs text-emerald-300">{saveStatus}</span>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div className="space-y-6">
|
||||
<section className="overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.12),_transparent_26%),linear-gradient(180deg,rgba(16,22,33,0.96),rgba(9,12,19,0.92))] shadow-[0_22px_80px_rgba(4,8,20,0.24)]">
|
||||
{coverImage ? (
|
||||
<div className="relative h-56 overflow-hidden border-b border-white/10">
|
||||
<img src={coverImage} alt="Story cover" className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/25 to-transparent" />
|
||||
<div className="absolute bottom-4 left-4 rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs uppercase tracking-[0.24em] text-white/75">Cover preview</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-5 p-6 md:p-8">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.22em] text-white/42">
|
||||
<span>{storyTypes.find((type) => type.slug === storyType)?.name || 'Story'}</span>
|
||||
<span>{status.replace(/_/g, ' ')}</span>
|
||||
{scheduledFor ? <span>Scheduled {scheduledFor}</span> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Give the story a title worth opening"
|
||||
className="w-full border-0 bg-transparent px-0 text-4xl font-semibold tracking-tight text-white placeholder:text-white/25 focus:outline-none md:text-5xl"
|
||||
/>
|
||||
{titleError ? <p className="mt-2 text-sm text-rose-300">{titleError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
value={excerpt}
|
||||
onChange={(event) => setExcerpt(event.target.value)}
|
||||
placeholder="Write a short dek that explains why this story matters."
|
||||
rows={3}
|
||||
className="w-full resize-none border-0 bg-transparent px-0 text-base leading-7 text-white/70 placeholder:text-white/25 focus:outline-none"
|
||||
/>
|
||||
{excerptError ? <p className="mt-2 text-sm text-rose-300">{excerptError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Words</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{wordCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Reading time</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{readMinutes} min</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Status</p>
|
||||
<p className="mt-2 text-2xl font-semibold capitalize text-white">{status.replace(/_/g, ' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,19,28,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_90px_rgba(4,8,20,0.28)]">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{editor ? (
|
||||
<>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bold') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('italic') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleItalic().run()}>Italic</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('underline') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleUnderline().run()}>Underline</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 2 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 3 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bulletList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBulletList().run()}>Bullets</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('orderedList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleOrderedList().run()}>Numbers</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('blockquote') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
|
||||
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('codeBlock') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={toggleCodeBlockWithLanguage}>Code block</button>
|
||||
<div className="inline-flex items-center gap-2 rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75">
|
||||
<span className="text-white/50">Lang</span>
|
||||
<div className="min-w-[10rem]">
|
||||
<Select
|
||||
value={codeBlockLanguage}
|
||||
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
|
||||
options={CODE_BLOCK_LANGUAGES}
|
||||
size="sm"
|
||||
className="border-white/10 bg-slate-950/90 py-1 text-sm text-white hover:border-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75" onClick={() => openLinkPrompt(editor)}>Link</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInsertMenu && (
|
||||
<div className="border-b border-white/10 bg-white/[0.03] px-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4">
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.uploadImage}>Upload image</button>
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.image}>Image URL</button>
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.artwork}>Embed artwork</button>
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.gallery}>Gallery</button>
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.video}>Video</button>
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.download}>Download</button>
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.quote}>Quote</button>
|
||||
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.code}>Code block</button>
|
||||
<div className="col-span-2 rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 sm:col-span-3 xl:col-span-2">
|
||||
<div className="mb-2 text-sm text-white/45">Language</div>
|
||||
<Select
|
||||
value={codeBlockLanguage}
|
||||
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
|
||||
options={CODE_BLOCK_LANGUAGES}
|
||||
size="sm"
|
||||
className="border-white/10 bg-slate-950/90 text-white hover:border-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editor && inlineToolbar.visible && (
|
||||
<div
|
||||
className="fixed z-40 flex items-center gap-1 rounded-2xl border border-white/10 bg-slate-950/95 px-2 py-1 shadow-lg backdrop-blur"
|
||||
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
|
||||
>
|
||||
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
|
||||
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
|
||||
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('underline') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleUnderline().run()}>U</button>
|
||||
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
|
||||
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-8 md:px-10 md:py-10">
|
||||
<EditorContent editor={editor} />
|
||||
{contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null}
|
||||
</div>
|
||||
|
||||
{showLivePreview && (
|
||||
<div className="border-t border-white/10 bg-white/[0.02] px-6 py-6 md:px-10">
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-white/40">Live preview</div>
|
||||
<div className="prose prose-invert max-w-none prose-pre:bg-slate-950 prose-p:text-stone-200" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showInsertMenu && (
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-gray-700 bg-gray-900/90 p-2 sm:grid-cols-3">
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.image}>Image</button>
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.artwork}>Embed Artwork</button>
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.code}>Code Block</button>
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.quote}>Quote</button>
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.divider}>Divider</button>
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.gallery}>Gallery</button>
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.video}>Video Embed</button>
|
||||
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.download}>Download Asset</button>
|
||||
</div>
|
||||
)}
|
||||
<aside className="space-y-4 xl:sticky xl:top-24 self-start">
|
||||
{(generalError || captchaState.required) && (
|
||||
<section className="rounded-[1.5rem] border border-amber-400/20 bg-amber-500/10 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-amber-100/70">Action needed</p>
|
||||
<p className="mt-3 text-sm text-amber-50">{generalError || captchaState.message || 'Complete the captcha challenge to continue.'}</p>
|
||||
{captchaState.required && captchaState.siteKey ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
|
||||
<TurnstileField
|
||||
key={`story-editor-captcha-${captchaState.nonce}`}
|
||||
provider={captchaState.provider}
|
||||
siteKey={captchaState.siteKey}
|
||||
scriptUrl={captchaState.scriptUrl}
|
||||
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
|
||||
className="min-h-16"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{editor && inlineToolbar.visible && (
|
||||
<div
|
||||
className="fixed z-40 flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-900 px-2 py-1 shadow-lg"
|
||||
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
|
||||
>
|
||||
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
|
||||
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
|
||||
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
|
||||
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
|
||||
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
|
||||
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
|
||||
</div>
|
||||
)}
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Publish checklist</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{readinessChecks.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/8 bg-black/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-white">{item.label}</span>
|
||||
<span className={`text-xs font-semibold uppercase tracking-[0.18em] ${item.ok ? 'text-emerald-300' : 'text-amber-200'}`}>{item.ok ? 'Ready' : 'Needs work'}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-white/48">{item.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Story settings</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Story type</label>
|
||||
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white">
|
||||
{storyTypes.map((type) => (
|
||||
<option key={type.slug} value={type.slug}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showLivePreview && (
|
||||
<div className="mt-4 rounded-xl border border-gray-700 bg-gray-900/60 p-4">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Live Preview</div>
|
||||
<div className="prose prose-invert max-w-none prose-pre:bg-gray-900" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Tags</label>
|
||||
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="art direction, process, workflow" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
|
||||
{tagsError ? <p className="mt-2 text-sm text-rose-300">{tagsError}</p> : <p className="mt-2 text-xs text-white/40">Comma-separated. New tags are created automatically.</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Workflow status</label>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending_review">Pending Review</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white/80">Schedule publish</label>
|
||||
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Cover</p>
|
||||
<button type="button" onClick={() => coverImageInputRef.current?.click()} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-white/78">Upload</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="https://..." className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
|
||||
{coverImage ? <img src={coverImage} alt="Cover preview" className="h-40 w-full rounded-2xl object-cover" /> : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-sm text-white/38">Add a cover image to give the story more presence in feeds.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">SEO & social</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
|
||||
<textarea value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} rows={3} placeholder="Meta description" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
|
||||
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
|
||||
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -791,6 +1244,9 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
|
||||
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />
|
||||
|
||||
{artworkModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-lg">
|
||||
|
||||
69
resources/js/components/leaderboard/LeaderboardItem.jsx
Normal file
69
resources/js/components/leaderboard/LeaderboardItem.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const PODIUM_STYLES = {
|
||||
1: 'border-yellow-300/40 bg-[linear-gradient(180deg,rgba(250,204,21,0.18),rgba(15,23,42,0.84))]',
|
||||
2: 'border-slate-300/30 bg-[linear-gradient(180deg,rgba(226,232,240,0.16),rgba(15,23,42,0.84))]',
|
||||
3: 'border-amber-700/40 bg-[linear-gradient(180deg,rgba(180,83,9,0.22),rgba(15,23,42,0.84))]',
|
||||
}
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function formatScore(score) {
|
||||
return new Intl.NumberFormat().format(Math.round(Number(score || 0)))
|
||||
}
|
||||
|
||||
export default function LeaderboardItem({ item, type, highlight = false }) {
|
||||
const entity = item?.entity || {}
|
||||
const rank = Number(item?.rank || 0)
|
||||
const tone = highlight ? PODIUM_STYLES[rank] || PODIUM_STYLES[3] : 'border-white/10 bg-white/[0.03]'
|
||||
const image = entity.avatar || entity.image || null
|
||||
|
||||
return (
|
||||
<article className={cx('rounded-3xl border p-4 shadow-lg transition', tone)}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cx('flex shrink-0 items-center justify-center rounded-2xl border font-black', highlight ? 'h-14 w-14 text-xl' : 'h-11 w-11 text-base', 'border-white/10 bg-slate-950/70 text-white')}>
|
||||
#{rank}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<a href={entity.url || '#'} className="block text-lg font-semibold text-white hover:text-sky-300 transition">
|
||||
{entity.name || 'Unknown'}
|
||||
</a>
|
||||
{entity.creator_name ? (
|
||||
<a href={entity.creator_url || '#'} className="mt-1 block text-sm text-slate-400 hover:text-sky-300 transition">
|
||||
by {entity.creator_name}
|
||||
</a>
|
||||
) : null}
|
||||
{entity.username ? <p className="mt-1 text-sm text-slate-500">@{entity.username}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-slate-500">Score</p>
|
||||
<p className="mt-1 text-2xl font-black text-white">{formatScore(item?.score)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
{type === 'creator' ? <LevelBadge level={entity.level} rank={entity.rank} compact /> : null}
|
||||
{type !== 'creator' && entity.creator_name ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.14em] text-slate-300">
|
||||
{type}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{image ? (
|
||||
<a href={entity.url || '#'} className={cx('block shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-900', type === 'creator' ? 'h-16 w-16' : 'h-20 w-24')}>
|
||||
<img src={image} alt={entity.name || 'Leaderboard item'} className="h-full w-full object-cover" loading="lazy" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
44
resources/js/components/leaderboard/LeaderboardList.jsx
Normal file
44
resources/js/components/leaderboard/LeaderboardList.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import LeaderboardItem from './LeaderboardItem'
|
||||
|
||||
export default function LeaderboardList({ items = [], type }) {
|
||||
const podium = items.slice(0, 3)
|
||||
const rest = items.slice(3)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{podium.length > 0 ? (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-slate-400">Top 3</h2>
|
||||
<span className="text-xs text-slate-500">Podium leaders</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{podium.map((item) => (
|
||||
<LeaderboardItem key={`${type}-${item.rank}`} item={item} type={type} highlight />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-slate-400">Leaderboard</h2>
|
||||
<span className="text-xs text-slate-500">{items.length} ranked entries</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] px-6 py-10 text-sm text-slate-400">
|
||||
No leaderboard entries available yet for this period.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{rest.map((item) => (
|
||||
<LeaderboardItem key={`${type}-${item.rank}`} item={item} type={type} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
resources/js/components/leaderboard/LeaderboardTabs.jsx
Normal file
35
resources/js/components/leaderboard/LeaderboardTabs.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function LeaderboardTabs({ items, active, onChange, sticky = false, label }) {
|
||||
return (
|
||||
<div className={cx(sticky ? 'sticky top-16 z-20' : '', 'rounded-2xl border border-white/10 bg-slate-950/85 p-2 backdrop-blur') }>
|
||||
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label={label || 'Leaderboard tabs'}>
|
||||
{items.map((item) => {
|
||||
const isActive = item.value === active
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onChange(item.value)}
|
||||
className={cx(
|
||||
'rounded-full px-4 py-2 text-sm font-semibold transition',
|
||||
isActive
|
||||
? 'bg-sky-400 text-slate-950 shadow-[0_12px_30px_rgba(56,189,248,0.28)]'
|
||||
: 'bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
resources/js/components/profile/ProfileGalleryPanel.jsx
Normal file
109
resources/js/components/profile/ProfileGalleryPanel.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useState } from 'react'
|
||||
import MasonryGallery from '../gallery/MasonryGallery'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: 'Latest' },
|
||||
{ value: 'trending', label: 'Trending' },
|
||||
{ value: 'rising', label: 'Rising' },
|
||||
{ value: 'views', label: 'Most Viewed' },
|
||||
{ value: 'favs', label: 'Most Favourited' },
|
||||
]
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function FeaturedStrip({ featuredArtworks }) {
|
||||
if (!featuredArtworks?.length) return null
|
||||
|
||||
return (
|
||||
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
|
||||
<i className="fa-solid fa-star fa-fw text-amber-400" />
|
||||
Featured
|
||||
</h2>
|
||||
<div className="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
|
||||
{featuredArtworks.slice(0, 5).map((art) => (
|
||||
<a
|
||||
key={art.id}
|
||||
href={`/art/${art.id}/${slugify(art.name)}`}
|
||||
className="group w-56 shrink-0 snap-start md:w-64"
|
||||
>
|
||||
<div className="aspect-[5/3] overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 transition-all hover:ring-sky-400/40">
|
||||
<img
|
||||
src={art.thumb}
|
||||
alt={art.name}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-sm text-slate-300 transition-colors group-hover:text-white">
|
||||
{art.name}
|
||||
</p>
|
||||
{art.label ? <p className="truncate text-[11px] text-slate-600">{art.label}</p> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileGalleryPanel({ artworks, featuredArtworks, username }) {
|
||||
const [sort, setSort] = useState('latest')
|
||||
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
|
||||
|
||||
const handleSort = async (newSort) => {
|
||||
setSort(newSort)
|
||||
setItems([])
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${newSort}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setItems(data.data ?? data)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FeaturedStrip featuredArtworks={featuredArtworks} />
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleSort(opt.value)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
sort === opt.value
|
||||
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MasonryGallery
|
||||
key={`profile-${username}-${sort}`}
|
||||
artworks={items}
|
||||
galleryType="profile"
|
||||
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
|
||||
initialNextCursor={nextCursor}
|
||||
limit={24}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +1,23 @@
|
||||
import React, { useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
import ProfileCoverEditor from './ProfileCoverEditor'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import XPProgressBar from '../xp/XPProgressBar'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
/**
|
||||
* ProfileHero
|
||||
* Cover banner + avatar + identity block + action buttons
|
||||
*/
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName }) {
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
||||
const [following, setFollowing] = useState(viewerIsFollowing)
|
||||
const [count, setCount] = useState(followerCount)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hovering, setHovering] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
|
||||
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [pendingFollowState, setPendingFollowState] = useState(null)
|
||||
|
||||
const uname = user.username || user.name || 'Unknown'
|
||||
const displayName = user.name || uname
|
||||
|
||||
const joinDate = user.created_at
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
: null
|
||||
|
||||
const bio = profile?.bio || profile?.about || ''
|
||||
|
||||
const persistFollowState = async (nextState) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/@${uname.toLowerCase()}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setFollowing(data.following)
|
||||
setCount(data.follower_count)
|
||||
}
|
||||
} catch (_) {}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const toggleFollow = async () => {
|
||||
const nextState = !following
|
||||
if (!nextState) {
|
||||
setPendingFollowState(nextState)
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await persistFollowState(nextState)
|
||||
}
|
||||
|
||||
const onConfirmUnfollow = async () => {
|
||||
if (pendingFollowState === null) return
|
||||
setConfirmOpen(false)
|
||||
await persistFollowState(pendingFollowState)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
const onCloseConfirm = () => {
|
||||
setConfirmOpen(false)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-6xl mx-auto px-4 pt-4">
|
||||
@@ -82,7 +31,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{isOwner && (
|
||||
{isOwner ? (
|
||||
<div className="absolute right-3 top-3 z-20">
|
||||
<button
|
||||
type="button"
|
||||
@@ -94,7 +43,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
Edit Cover
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
@@ -109,49 +58,58 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
</div>
|
||||
|
||||
<div className="relative -mt-14 md:-mt-16 pb-4 px-1">
|
||||
<div className="flex flex-col md:flex-row md:items-end gap-4 md:gap-5">
|
||||
<div className="mx-auto md:mx-0 shrink-0 z-10">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-5">
|
||||
<div className="mx-auto z-10 shrink-0 md:mx-0">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
alt={`${uname}'s avatar`}
|
||||
className="w-[104px] h-[104px] md:w-[116px] md:h-[116px] rounded-full object-cover border-2 border-white/15 shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)]"
|
||||
className="h-[104px] w-[104px] rounded-full border-2 border-white/15 object-cover shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)] md:h-[116px] md:w-[116px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-center md:text-left">
|
||||
<h1 className="text-[28px] md:text-[34px] font-bold text-white leading-tight tracking-tight">
|
||||
<div className="min-w-0 flex-1 text-center md:text-left">
|
||||
<h1 className="text-[28px] font-bold leading-tight tracking-tight text-white md:text-[34px]">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
|
||||
<p className="mt-0.5 font-mono text-sm text-slate-400">@{uname}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2.5 mt-2 text-xs text-slate-400">
|
||||
{countryName && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
|
||||
{profile?.country_code && (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<LevelBadge level={user?.level} rank={user?.rank} />
|
||||
{leaderboardRank?.rank ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-100">
|
||||
Rank #{leaderboardRank.rank} this week
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2.5 text-xs text-slate-400 md:justify-start">
|
||||
{countryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
{profile?.country_code ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
onError={(event) => { event.target.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
{countryName}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{joinDate && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
|
||||
{joinDate ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
|
||||
Joined {joinDate}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{profile?.website && (
|
||||
{profile?.website ? (
|
||||
<a
|
||||
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 text-sky-300 hover:text-sky-200 hover:bg-white/10 transition-colors"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-sky-300 transition-colors hover:bg-white/10 hover:text-sky-200"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw" />
|
||||
{(() => {
|
||||
@@ -163,22 +121,32 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
}
|
||||
})()}
|
||||
</a>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bio && (
|
||||
<p className="text-sm text-slate-300/90 mt-3 max-w-2xl leading-relaxed line-clamp-2 md:line-clamp-3 mx-auto md:mx-0">
|
||||
{bio ? (
|
||||
<p className="mx-auto mt-3 max-w-2xl line-clamp-2 text-sm leading-relaxed text-slate-300/90 md:mx-0 md:line-clamp-3">
|
||||
{bio}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<XPProgressBar
|
||||
xp={user?.xp}
|
||||
currentLevelXp={user?.current_level_xp}
|
||||
nextLevelXp={user?.next_level_xp}
|
||||
progressPercent={user?.progress_percent}
|
||||
maxLevel={user?.max_level}
|
||||
className="mt-4 max-w-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center justify-center md:justify-end gap-2 pb-0.5">
|
||||
<div className="shrink-0 flex items-center justify-center gap-2 pb-0.5 md:justify-end">
|
||||
{extraActions}
|
||||
{isOwner ? (
|
||||
<>
|
||||
<a
|
||||
href="/dashboard/profile"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
|
||||
aria-label="Edit profile"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw" />
|
||||
@@ -186,7 +154,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
</a>
|
||||
<a
|
||||
href="/studio"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-sky-900/30 transition-all hover:bg-sky-500"
|
||||
aria-label="Open Studio"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
@@ -195,32 +163,20 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleFollow}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
disabled={loading}
|
||||
aria-label={following ? 'Unfollow' : 'Follow'}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border transition-all ${
|
||||
following
|
||||
? hovering
|
||||
? 'bg-red-500/10 border-red-400/40 text-red-400'
|
||||
: 'bg-green-500/10 border-green-400/40 text-green-400'
|
||||
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${
|
||||
loading
|
||||
? 'fa-circle-notch fa-spin'
|
||||
: following
|
||||
? hovering ? 'fa-user-minus' : 'fa-user-check'
|
||||
: 'fa-user-plus'
|
||||
}`} />
|
||||
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
||||
<span className="text-xs opacity-70">{count.toLocaleString()}</span>
|
||||
</button>
|
||||
<FollowButton
|
||||
username={uname}
|
||||
initialFollowing={following}
|
||||
initialCount={count}
|
||||
followingClassName="bg-green-500/10 border border-green-400/40 text-green-400 hover:bg-green-500/15"
|
||||
idleClassName="bg-sky-500/10 border border-sky-400/40 text-sky-400 hover:bg-sky-500/20"
|
||||
onChange={({ following: nextFollowing, followersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setCount(followersCount)
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
|
||||
@@ -229,7 +185,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
}
|
||||
}}
|
||||
aria-label="Share profile"
|
||||
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
className="rounded-xl border border-white/10 p-2.5 text-slate-400 transition-all hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
</button>
|
||||
@@ -241,8 +197,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
</div>
|
||||
|
||||
<ProfileCoverEditor
|
||||
isOpen={editorOpen}
|
||||
open={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
currentCoverUrl={coverUrl}
|
||||
currentPosition={coverPosition}
|
||||
coverUrl={coverUrl}
|
||||
coverPosition={coverPosition}
|
||||
onCoverUpdated={(nextUrl, nextPosition) => {
|
||||
@@ -254,18 +212,6 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
setCoverPosition(50)
|
||||
}}
|
||||
/>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={`You will stop seeing updates from @${uname} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={onConfirmUnfollow}
|
||||
onClose={onCloseConfirm}
|
||||
busy={loading}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useRef } from 'react'
|
||||
export const TABS = [
|
||||
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
|
||||
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
|
||||
{ id: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
|
||||
{ id: 'about', label: 'About', icon: 'fa-id-card' },
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.country_code && (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
|
||||
35
resources/js/components/profile/tabs/TabAchievements.jsx
Normal file
35
resources/js/components/profile/tabs/TabAchievements.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import AchievementsList from '../../achievements/AchievementsList'
|
||||
|
||||
export default function TabAchievements({ achievements }) {
|
||||
const unlocked = Array.isArray(achievements?.unlocked) ? achievements.unlocked : []
|
||||
const locked = Array.isArray(achievements?.locked) ? achievements.locked : []
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-achievements"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-achievements"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Achievements</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
Milestones, creator wins, and level-based unlocks collected on Skinbase.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
|
||||
{achievements?.counts?.unlocked || 0} unlocked
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
|
||||
{achievements?.counts?.total || 0} total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AchievementsList unlocked={unlocked} locked={locked} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import LevelBadge from '../../xp/LevelBadge'
|
||||
|
||||
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
@@ -22,6 +23,7 @@ function CommentItem({ comment }) {
|
||||
>
|
||||
{comment.author_name}
|
||||
</a>
|
||||
<LevelBadge level={comment.author_level} rank={comment.author_rank} compact />
|
||||
<span className="text-slate-600 text-xs ml-auto whitespace-nowrap">
|
||||
{(() => {
|
||||
try {
|
||||
|
||||
@@ -1,82 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import MasonryGallery from '../../gallery/MasonryGallery'
|
||||
import React from 'react'
|
||||
import ProfileGalleryPanel from '../ProfileGalleryPanel'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: 'Latest' },
|
||||
{ value: 'trending', label: 'Trending' },
|
||||
{ value: 'rising', label: 'Rising' },
|
||||
{ value: 'views', label: 'Most Viewed' },
|
||||
{ value: 'favs', label: 'Most Favourited' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Featured artworks horizontal scroll strip.
|
||||
*/
|
||||
function FeaturedStrip({ featuredArtworks }) {
|
||||
if (!featuredArtworks?.length) return null
|
||||
|
||||
return (
|
||||
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-star text-amber-400 fa-fw" />
|
||||
Featured
|
||||
</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||
{featuredArtworks.slice(0, 5).map((art) => (
|
||||
<a
|
||||
key={art.id}
|
||||
href={`/art/${art.id}/${slugify(art.name)}`}
|
||||
className="group shrink-0 snap-start w-56 md:w-64"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[5/3] hover:ring-sky-400/40 transition-all">
|
||||
<img
|
||||
src={art.thumb}
|
||||
alt={art.name}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 mt-2 truncate group-hover:text-white transition-colors">
|
||||
{art.name}
|
||||
</p>
|
||||
{art.label && (
|
||||
<p className="text-[11px] text-slate-600 truncate">{art.label}</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* TabArtworks
|
||||
* Features: sort selector, featured strip, masonry-style artwork grid,
|
||||
* skeleton loading, empty state, load-more pagination.
|
||||
*/
|
||||
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) {
|
||||
const [sort, setSort] = useState('latest')
|
||||
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
|
||||
|
||||
const handleSort = async (newSort) => {
|
||||
setSort(newSort)
|
||||
setItems([])
|
||||
try {
|
||||
const res = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${newSort}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems(data.data ?? data)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,37 +10,10 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
|
||||
aria-labelledby="tab-artworks"
|
||||
className="pt-6"
|
||||
>
|
||||
{/* Featured strip */}
|
||||
<FeaturedStrip featuredArtworks={featuredArtworks} />
|
||||
|
||||
{/* Sort bar */}
|
||||
<div className="flex items-center gap-3 mb-5 flex-wrap">
|
||||
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Sort</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleSort(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
sort === opt.value
|
||||
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shared masonry gallery component reused from discover/explore */}
|
||||
<MasonryGallery
|
||||
key={`profile-${username}-${sort}`}
|
||||
artworks={items}
|
||||
galleryType="profile"
|
||||
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
|
||||
initialNextCursor={nextCursor}
|
||||
limit={24}
|
||||
<ProfileGalleryPanel
|
||||
artworks={artworks}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={username}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import ArtworkGallery from '../../artwork/ArtworkGallery'
|
||||
|
||||
function FavSkeleton() {
|
||||
@@ -14,12 +14,22 @@ function FavSkeleton() {
|
||||
* Shows artworks the user has favourited.
|
||||
*/
|
||||
export default function TabFavourites({ favourites, isOwner, username }) {
|
||||
const [items, setItems] = useState(favourites ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(null)
|
||||
const initialItems = Array.isArray(favourites)
|
||||
? favourites
|
||||
: (favourites?.data ?? [])
|
||||
const [items, setItems] = useState(initialItems)
|
||||
const [nextCursor, setNextCursor] = useState(favourites?.next_cursor ?? null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const loadMoreRef = useRef(null)
|
||||
|
||||
const loadMore = async () => {
|
||||
useEffect(() => {
|
||||
setItems(initialItems)
|
||||
setNextCursor(favourites?.next_cursor ?? null)
|
||||
}, [favourites, initialItems])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
@@ -33,7 +43,30 @@ export default function TabFavourites({ favourites, isOwner, username }) {
|
||||
}
|
||||
} catch (_) {}
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [loadingMore, nextCursor, username])
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current
|
||||
|
||||
if (!node || !nextCursor) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
loadMore()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '320px 0px',
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(node)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [loadMore, nextCursor])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -68,6 +101,10 @@ export default function TabFavourites({ favourites, isOwner, username }) {
|
||||
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
|
||||
</ArtworkGallery>
|
||||
|
||||
{nextCursor && (
|
||||
<div ref={loadMoreRef} className="h-6 w-full" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{nextCursor && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import LevelBadge from '../../xp/LevelBadge'
|
||||
|
||||
export default function TabStories({ stories, username }) {
|
||||
const list = Array.isArray(stories) ? stories : []
|
||||
@@ -26,6 +27,10 @@ export default function TabStories({ stories, username }) {
|
||||
<div className="h-44 w-full bg-gradient-to-br from-gray-900 via-slate-900 to-sky-950" />
|
||||
)}
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<LevelBadge level={story.creator_level} rank={story.creator_rank} compact />
|
||||
<span className="text-[11px] uppercase tracking-[0.16em] text-gray-500">Story</span>
|
||||
</div>
|
||||
<h3 className="line-clamp-2 text-base font-semibold text-white">{story.title}</h3>
|
||||
<p className="line-clamp-2 text-xs text-gray-300">{story.excerpt || ''}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
|
||||
33
resources/js/components/social/BookmarkButton.jsx
Normal file
33
resources/js/components/social/BookmarkButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function BookmarkButton({ active = false, count = 0, onToggle, label = 'Save', activeLabel = 'Saved' }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!onToggle || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onToggle()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={busy}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-60',
|
||||
active
|
||||
? 'border-amber-400/30 bg-amber-400/12 text-amber-200'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${busy ? 'fa-circle-notch fa-spin' : 'fa-bookmark'}`} />
|
||||
<span>{active ? activeLabel : label}</span>
|
||||
<span className="text-xs opacity-80">{Number(count || 0).toLocaleString()}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
61
resources/js/components/social/CommentForm.jsx
Normal file
61
resources/js/components/social/CommentForm.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
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 handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed || busy) return
|
||||
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit?.(trimmed)
|
||||
setContent('')
|
||||
} catch (submitError) {
|
||||
setError(submitError?.message || 'Unable to post comment.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
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]"
|
||||
/>
|
||||
|
||||
{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>
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || content.trim().length === 0}
|
||||
className="rounded-full bg-sky-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? 'Posting…' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
87
resources/js/components/social/CommentList.jsx
Normal file
87
resources/js/components/social/CommentList.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import CommentForm from './CommentForm'
|
||||
|
||||
function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
return (
|
||||
<article id={`story-comment-${comment.id}`} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-4">
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={comment.user?.avatar_url || 'https://files.skinbase.org/default/avatar_default.webp'}
|
||||
alt={comment.user?.display || 'User'}
|
||||
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{comment.user?.profile_url ? (
|
||||
<a href={comment.user.profile_url} className="text-sm font-semibold text-white hover:text-sky-300">
|
||||
{comment.user.display}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-white">{comment.user?.display || 'User'}</span>
|
||||
)}
|
||||
<LevelBadge level={comment.user?.level} rank={comment.user?.rank} compact />
|
||||
<span className="text-xs uppercase tracking-[0.16em] text-white/30">{comment.time_ago}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="prose prose-invert prose-sm mt-2 max-w-none text-white/80 prose-p:my-1.5 prose-a:text-sky-300"
|
||||
dangerouslySetInnerHTML={{ __html: comment.rendered_content || '' }}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center gap-3 text-xs text-white/45">
|
||||
{canReply ? (
|
||||
<button type="button" onClick={() => setReplying((value) => !value)} className="transition hover:text-white">
|
||||
Reply
|
||||
</button>
|
||||
) : null}
|
||||
{comment.can_delete ? (
|
||||
<button type="button" onClick={() => onDelete?.(comment.id)} className="transition hover:text-rose-300">
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{replying ? (
|
||||
<div className="mt-3">
|
||||
<CommentForm
|
||||
compact
|
||||
placeholder={`Reply to ${comment.user?.display || 'user'}…`}
|
||||
submitLabel="Reply"
|
||||
onCancel={() => setReplying(false)}
|
||||
onSubmit={async (content) => {
|
||||
await onReply?.(comment.id, content)
|
||||
setReplying(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, emptyMessage = 'No comments yet.' }) {
|
||||
if (!comments.length) {
|
||||
return <p className="text-sm text-white/45">{emptyMessage}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
resources/js/components/social/FollowButton.jsx
Normal file
97
resources/js/components/social/FollowButton.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
|
||||
export default function FollowButton({
|
||||
username,
|
||||
initialFollowing = false,
|
||||
initialCount = 0,
|
||||
showCount = true,
|
||||
className = '',
|
||||
followingClassName = 'bg-white/[0.04] border border-white/[0.08] text-white/75 hover:bg-white/[0.08]',
|
||||
idleClassName = 'bg-accent text-deep hover:brightness-110',
|
||||
sizeClassName = 'px-4 py-2.5 text-sm',
|
||||
confirmMessage,
|
||||
onChange,
|
||||
}) {
|
||||
const [following, setFollowing] = useState(Boolean(initialFollowing))
|
||||
const [count, setCount] = useState(Number(initialCount || 0))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const persist = async (nextState) => {
|
||||
if (!username || loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/user/${encodeURIComponent(username)}/follow`, {
|
||||
method: nextState ? 'POST' : 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Follow request failed')
|
||||
const payload = await response.json()
|
||||
const nextFollowing = Boolean(payload?.following)
|
||||
const nextCount = Number(payload?.followers_count ?? count)
|
||||
setFollowing(nextFollowing)
|
||||
setCount(nextCount)
|
||||
onChange?.({ following: nextFollowing, followersCount: nextCount })
|
||||
} catch {
|
||||
// Keep previous state on failure.
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggle = async () => {
|
||||
if (!following) {
|
||||
await persist(true)
|
||||
return
|
||||
}
|
||||
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const toneClassName = following ? followingClassName : idleClassName
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={loading || !username}
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
className={[
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all disabled:cursor-not-allowed disabled:opacity-60',
|
||||
sizeClassName,
|
||||
toneClassName,
|
||||
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>
|
||||
{showCount ? <span className="text-xs opacity-70">{count.toLocaleString()}</span> : null}
|
||||
</button>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={confirmMessage || `You will stop seeing updates from @${username} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={async () => {
|
||||
setConfirmOpen(false)
|
||||
await persist(false)
|
||||
}}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
resources/js/components/social/LikeButton.jsx
Normal file
33
resources/js/components/social/LikeButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function LikeButton({ active = false, count = 0, onToggle, label = 'Like', activeLabel = 'Liked' }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!onToggle || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onToggle()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={busy}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-60',
|
||||
active
|
||||
? 'border-rose-500/30 bg-rose-500/12 text-rose-300'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${busy ? 'fa-circle-notch fa-spin' : active ? 'fa-heart' : 'fa-heart'}`} />
|
||||
<span>{active ? activeLabel : label}</span>
|
||||
<span className="text-xs opacity-80">{Number(count || 0).toLocaleString()}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
163
resources/js/components/social/NotificationDropdown.jsx
Normal file
163
resources/js/components/social/NotificationDropdown.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export default function NotificationDropdown({ initialUnreadCount = 0, notificationsUrl = '/api/notifications' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState([])
|
||||
const [unreadCount, setUnreadCount] = useState(Number(initialUnreadCount || 0))
|
||||
const rootRef = useRef(null)
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const onDocumentClick = (event) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onDocumentClick)
|
||||
return () => document.removeEventListener('mousedown', onDocumentClick)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || items.length > 0) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(notificationsUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error('Failed to load notifications')
|
||||
return response.json()
|
||||
})
|
||||
.then((payload) => {
|
||||
setItems(Array.isArray(payload?.data) ? payload.data : [])
|
||||
setUnreadCount(Number(payload?.unread_count || 0))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [items.length, notificationsUrl, open])
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await fetch('/api/notifications/read-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
setItems((current) => current.map((item) => ({ ...item, read: true })))
|
||||
setUnreadCount(0)
|
||||
} catch {
|
||||
// Keep current state on failure.
|
||||
}
|
||||
}
|
||||
|
||||
const markSingleRead = async (id) => {
|
||||
await fetch(`/api/notifications/${id}/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
setItems((current) => current.map((item) => item.id === id ? { ...item, read: true } : item))
|
||||
setUnreadCount((current) => Math.max(0, current - 1))
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (event, item) => {
|
||||
if (!item?.id || item.read) return
|
||||
|
||||
const href = event.currentTarget.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
await markSingleRead(item.id)
|
||||
} catch {
|
||||
// Continue to the destination even if marking as read fails.
|
||||
}
|
||||
|
||||
window.location.assign(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<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"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg className="h-5 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">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute right-0 mt-2 w-[22rem] overflow-hidden rounded-2xl border border-white/[0.08] bg-panel shadow-2xl shadow-black/35">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Notifications</h3>
|
||||
<p className="text-xs text-white/35">Recent follows, likes, comments, and unlocks.</p>
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<button type="button" onClick={markAllRead} className="text-xs font-medium text-sky-300 transition hover:text-sky-200">
|
||||
Mark all read
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[28rem] overflow-y-auto">
|
||||
{loading ? <div className="px-4 py-6 text-sm text-white/45">Loading notifications…</div> : null}
|
||||
{!loading && items.length === 0 ? <div className="px-4 py-6 text-sm text-white/45">No notifications yet.</div> : null}
|
||||
{!loading && items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url || '/dashboard/comments/received'}
|
||||
onClick={(event) => handleNotificationClick(event, item)}
|
||||
className={[
|
||||
'flex gap-3 border-b border-white/[0.05] px-4 py-3 transition hover:bg-white/[0.04]',
|
||||
item.read ? 'bg-transparent' : 'bg-sky-500/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<img
|
||||
src={item.actor?.avatar_url || 'https://files.skinbase.org/default/avatar_default.webp'}
|
||||
alt={item.actor?.name || 'Notification'}
|
||||
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white/85">{item.message}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-white/30">{item.time_ago || ''}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] bg-white/[0.02] px-4 py-3">
|
||||
<a href="/dashboard/notifications" className="inline-flex items-center gap-2 text-sm font-medium text-sky-300 transition hover:text-sky-200">
|
||||
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
resources/js/components/social/StorySocialPanel.jsx
Normal file
161
resources/js/components/social/StorySocialPanel.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react'
|
||||
import FollowButton from './FollowButton'
|
||||
import LikeButton from './LikeButton'
|
||||
import BookmarkButton from './BookmarkButton'
|
||||
import CommentForm from './CommentForm'
|
||||
import CommentList from './CommentList'
|
||||
|
||||
export default function StorySocialPanel({ story, creator, initialState, initialComments, isAuthenticated = false }) {
|
||||
const [state, setState] = useState({
|
||||
liked: Boolean(initialState?.liked),
|
||||
bookmarked: Boolean(initialState?.bookmarked),
|
||||
likesCount: Number(initialState?.likes_count || 0),
|
||||
commentsCount: Number(initialState?.comments_count || 0),
|
||||
bookmarksCount: Number(initialState?.bookmarks_count || 0),
|
||||
})
|
||||
const [comments, setComments] = useState(Array.isArray(initialComments) ? initialComments : [])
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const postJson = async (url, method = 'POST', body = null) => {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const refreshCounts = (nextComments) => {
|
||||
const countReplies = (items) => items.reduce((sum, item) => sum + 1 + countReplies(item.replies || []), 0)
|
||||
return countReplies(nextComments)
|
||||
}
|
||||
|
||||
const insertReply = (items, parentId, newComment) => items.map((item) => {
|
||||
if (item.id === parentId) {
|
||||
return { ...item, replies: [...(item.replies || []), newComment] }
|
||||
}
|
||||
|
||||
if (Array.isArray(item.replies) && item.replies.length > 0) {
|
||||
return { ...item, replies: insertReply(item.replies, parentId, newComment) }
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
const deleteCommentRecursive = (items, commentId) => items
|
||||
.filter((item) => item.id !== commentId)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
replies: Array.isArray(item.replies) ? deleteCommentRecursive(item.replies, commentId) : [],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<LikeButton
|
||||
active={state.liked}
|
||||
count={state.likesCount}
|
||||
onToggle={async () => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await postJson(`/api/stories/${story.id}/like`, 'POST', { state: !state.liked })
|
||||
setState((current) => ({
|
||||
...current,
|
||||
liked: Boolean(payload?.liked),
|
||||
likesCount: Number(payload?.likes_count || 0),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
|
||||
<BookmarkButton
|
||||
active={state.bookmarked}
|
||||
count={state.bookmarksCount}
|
||||
onToggle={async () => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await postJson(`/api/stories/${story.id}/bookmark`, 'POST', { state: !state.bookmarked })
|
||||
setState((current) => ({
|
||||
...current,
|
||||
bookmarked: Boolean(payload?.bookmarked),
|
||||
bookmarksCount: Number(payload?.bookmarks_count || 0),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
|
||||
{creator?.username ? (
|
||||
<FollowButton
|
||||
username={creator.username}
|
||||
initialFollowing={Boolean(initialState?.is_following_creator)}
|
||||
initialCount={Number(creator.followers_count || 0)}
|
||||
className="min-w-[11rem]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Discussion</h2>
|
||||
<p className="text-sm text-white/40">{state.commentsCount.toLocaleString()} comments on this story</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<div className="mb-5">
|
||||
<CommentForm
|
||||
placeholder="Add to the story discussion…"
|
||||
submitLabel="Post Comment"
|
||||
onSubmit={async (content) => {
|
||||
const payload = await postJson(`/api/stories/${story.id}/comments`, 'POST', { content })
|
||||
const nextComments = [payload.data, ...comments]
|
||||
setComments(nextComments)
|
||||
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-5 text-sm text-white/45">
|
||||
<a href="/login" className="text-sky-300 hover:text-sky-200">Sign in</a> to join the discussion.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<CommentList
|
||||
comments={comments}
|
||||
canReply={isAuthenticated}
|
||||
emptyMessage="No comments yet. Start the discussion."
|
||||
onReply={async (parentId, content) => {
|
||||
const payload = await postJson(`/api/stories/${story.id}/comments`, 'POST', { content, parent_id: parentId })
|
||||
const nextComments = insertReply(comments, parentId, payload.data)
|
||||
setComments(nextComments)
|
||||
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
|
||||
}}
|
||||
onDelete={async (commentId) => {
|
||||
await postJson(`/api/stories/${story.id}/comments/${commentId}`, 'DELETE')
|
||||
const nextComments = deleteCommentRecursive(comments, commentId)
|
||||
setComments(nextComments)
|
||||
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
resources/js/components/xp/LevelBadge.jsx
Normal file
34
resources/js/components/xp/LevelBadge.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONES = {
|
||||
1: 'border-slate-500/30 bg-slate-500/10 text-slate-200',
|
||||
2: 'border-sky-400/35 bg-sky-500/10 text-sky-200',
|
||||
3: 'border-emerald-400/35 bg-emerald-500/10 text-emerald-200',
|
||||
4: 'border-fuchsia-400/35 bg-fuchsia-500/10 text-fuchsia-200',
|
||||
5: 'border-amber-400/35 bg-amber-500/10 text-amber-100',
|
||||
6: 'border-rose-400/35 bg-rose-500/10 text-rose-100',
|
||||
7: 'border-violet-400/35 bg-violet-500/10 text-violet-100',
|
||||
}
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function LevelBadge({ level = 1, rank = 'Newbie', compact = false, className = '' }) {
|
||||
const numericLevel = Number(level || 1)
|
||||
const tone = TONES[numericLevel] || TONES[1]
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 font-semibold tracking-[0.08em]',
|
||||
compact ? 'text-[10px] uppercase' : 'text-[11px] uppercase',
|
||||
tone,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span>LVL {numericLevel}</span>
|
||||
<span className="text-current/75">{rank}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
43
resources/js/components/xp/XPProgressBar.jsx
Normal file
43
resources/js/components/xp/XPProgressBar.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatXp(value) {
|
||||
return new Intl.NumberFormat().format(Number(value || 0))
|
||||
}
|
||||
|
||||
function clampPercent(value) {
|
||||
const numeric = Number(value || 0)
|
||||
if (!Number.isFinite(numeric)) return 0
|
||||
return Math.max(0, Math.min(100, numeric))
|
||||
}
|
||||
|
||||
function cx(...parts) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function XPProgressBar({
|
||||
xp = 0,
|
||||
currentLevelXp = 0,
|
||||
nextLevelXp = 100,
|
||||
progressPercent = 0,
|
||||
maxLevel = false,
|
||||
className = '',
|
||||
}) {
|
||||
const percent = maxLevel ? 100 : clampPercent(progressPercent)
|
||||
|
||||
return (
|
||||
<div className={cx('space-y-2', className)}>
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-slate-300">
|
||||
<span>{formatXp(xp)} XP</span>
|
||||
<span>
|
||||
{maxLevel ? 'Max level reached' : `${formatXp(currentLevelXp)} / ${formatXp(nextLevelXp)} XP`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-white/10 ring-1 ring-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#38bdf8_0%,#a78bfa_55%,#f59e0b_100%)] transition-[width] duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
function actorLabel(item) {
|
||||
if (!item.actor) {
|
||||
return 'System'
|
||||
if (!item?.user) {
|
||||
return 'Someone'
|
||||
}
|
||||
|
||||
return item.actor.username ? `@${item.actor.username}` : item.actor.name || 'User'
|
||||
return item.user.username ? `@${item.user.username}` : item.user.name || 'User'
|
||||
}
|
||||
|
||||
function describeActivity(item) {
|
||||
const artworkTitle = item?.artwork?.title || 'an artwork'
|
||||
const mentionTarget = item?.mentioned_user?.username || item?.mentioned_user?.name || 'someone'
|
||||
const reactionLabel = item?.reaction?.label || 'reacted'
|
||||
|
||||
switch (item?.type) {
|
||||
case 'comment':
|
||||
return `commented on ${artworkTitle}`
|
||||
case 'reply':
|
||||
return `replied on ${artworkTitle}`
|
||||
case 'reaction':
|
||||
return `${reactionLabel.toLowerCase()} on ${artworkTitle}`
|
||||
case 'mention':
|
||||
return `mentioned @${mentionTarget} on ${artworkTitle}`
|
||||
default:
|
||||
return 'shared new activity'
|
||||
}
|
||||
}
|
||||
|
||||
function timeLabel(dateString) {
|
||||
@@ -28,9 +47,15 @@ export default function ActivityFeed() {
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await window.axios.get('/api/dashboard/activity')
|
||||
const response = await window.axios.get('/api/activity', {
|
||||
params: {
|
||||
filter: 'my',
|
||||
per_page: 8,
|
||||
},
|
||||
})
|
||||
if (!cancelled) {
|
||||
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
|
||||
setError('')
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
@@ -77,14 +102,12 @@ export default function ActivityFeed() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm text-gray-100">
|
||||
<span className="font-semibold text-white">{actorLabel(item)}</span> {item.message}
|
||||
<span className="font-semibold text-white">{actorLabel(item)}</span> {describeActivity(item)}
|
||||
</p>
|
||||
{item.is_unread ? (
|
||||
<span className="rounded-full bg-cyan-500/20 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-cyan-200">
|
||||
unread
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.comment?.body ? (
|
||||
<p className="mt-2 line-clamp-2 text-xs text-gray-300">{item.comment.body}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-gray-400">{timeLabel(item.created_at)}</p>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -1,59 +1,124 @@
|
||||
import React from 'react'
|
||||
|
||||
const baseCard =
|
||||
'group rounded-xl border border-gray-700 bg-gray-800 p-4 shadow-lg transition hover:scale-[1.02] hover:border-cyan-500/40'
|
||||
'group rounded-2xl border border-white/10 bg-[#0b1826]/85 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]'
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: 'upload-artwork',
|
||||
label: 'Upload Artwork',
|
||||
href: '/upload',
|
||||
icon: 'fa-solid fa-cloud-arrow-up',
|
||||
description: 'Publish a new piece to your portfolio.',
|
||||
},
|
||||
{
|
||||
key: 'write-story',
|
||||
label: 'Write Story',
|
||||
href: '/creator/stories/create',
|
||||
icon: 'fa-solid fa-pen-nib',
|
||||
description: 'Create a story, tutorial, or showcase.',
|
||||
},
|
||||
{
|
||||
key: 'edit-profile',
|
||||
label: 'Edit Profile',
|
||||
href: '/settings/profile',
|
||||
icon: 'fa-solid fa-user-gear',
|
||||
description: 'Update your profile details and links.',
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
label: 'View Notifications',
|
||||
href: '/messages',
|
||||
icon: 'fa-solid fa-bell',
|
||||
description: 'Catch up with mentions and updates.',
|
||||
},
|
||||
]
|
||||
export default function QuickActions({ isCreator, receivedCommentsCount = 0, onNavigate }) {
|
||||
const actions = [
|
||||
{
|
||||
key: 'edit-profile',
|
||||
label: 'Edit Profile',
|
||||
href: '/dashboard/profile',
|
||||
icon: 'fa-solid fa-user-gear',
|
||||
description: 'Refresh your bio, socials, avatar, and country details.',
|
||||
},
|
||||
{
|
||||
key: 'received-comments',
|
||||
label: 'Review Feedback',
|
||||
href: '/dashboard/comments/received',
|
||||
icon: 'fa-solid fa-inbox',
|
||||
description: 'Read the latest comments left on your work.',
|
||||
badge: receivedCommentsCount > 0 ? `${receivedCommentsCount} new` : null,
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
label: 'Open Notifications',
|
||||
href: '/dashboard/notifications',
|
||||
icon: 'fa-solid fa-bell',
|
||||
description: 'Catch up on mentions, replies, and updates.',
|
||||
},
|
||||
...(isCreator
|
||||
? [
|
||||
{
|
||||
key: 'manage-artworks',
|
||||
label: 'Manage Artworks',
|
||||
href: '/dashboard/artworks',
|
||||
icon: 'fa-solid fa-layer-group',
|
||||
description: 'Edit titles, details, and the presentation of your portfolio.',
|
||||
},
|
||||
{
|
||||
key: 'write-story',
|
||||
label: 'Write Story',
|
||||
href: '/creator/stories/create',
|
||||
icon: 'fa-solid fa-pen-nib',
|
||||
description: 'Publish a tutorial, devlog, showcase, or announcement.',
|
||||
},
|
||||
{
|
||||
key: 'open-studio',
|
||||
label: 'Open Studio',
|
||||
href: '/studio',
|
||||
icon: 'fa-solid fa-compass-drafting',
|
||||
description: 'Jump into the wider creator workspace and analytics tools.',
|
||||
},
|
||||
{
|
||||
key: 'creator-stories',
|
||||
label: 'Story Dashboard',
|
||||
href: '/creator/stories',
|
||||
icon: 'fa-solid fa-newspaper',
|
||||
description: 'Review creator stories, drafts, and publishing flow in one place.',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'upload-artwork',
|
||||
label: 'Upload Artwork',
|
||||
href: '/upload',
|
||||
icon: 'fa-solid fa-cloud-arrow-up',
|
||||
description: 'Start publishing and unlock more creator-focused dashboard tools.',
|
||||
},
|
||||
{
|
||||
key: 'explore-trending',
|
||||
label: 'Explore Trending',
|
||||
href: '/discover/trending',
|
||||
icon: 'fa-solid fa-fire-flame-curved',
|
||||
description: 'Browse what is hot right now to spot styles and creators worth following.',
|
||||
},
|
||||
{
|
||||
key: 'find-creators',
|
||||
label: 'Find Creators',
|
||||
href: '/creators/top',
|
||||
icon: 'fa-solid fa-user-group',
|
||||
description: 'Discover artists to follow and build a stronger feed around your taste.',
|
||||
},
|
||||
{
|
||||
key: 'saved-favorites',
|
||||
label: 'Open Favorites',
|
||||
href: '/dashboard/favorites',
|
||||
icon: 'fa-solid fa-bookmark',
|
||||
description: 'Revisit saved work and start shaping your own inspiration library.',
|
||||
},
|
||||
]),
|
||||
]
|
||||
|
||||
export default function QuickActions({ isCreator }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Quick Actions</h2>
|
||||
<span className="rounded-full border border-gray-600 px-2 py-1 text-xs text-gray-300">
|
||||
{isCreator ? 'Creator mode' : 'User mode'}
|
||||
<section className="rounded-[28px] border border-white/10 bg-[#08111c]/90 p-5 shadow-2xl shadow-black/20 sm:p-6">
|
||||
<div className="mb-5 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/80">Quick Actions</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Start something useful right now</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
{isCreator ? 'Creator Mode' : 'Member Mode'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{actions.map((action) => (
|
||||
<a key={action.key} href={action.href} className={baseCard}>
|
||||
<a key={action.key} href={action.href} onClick={() => onNavigate?.(action.href, action.label)} className={baseCard}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 text-cyan-300">
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-sky-200">
|
||||
<i className={action.icon} aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{action.label}</p>
|
||||
<p className="mt-1 text-xs text-gray-300">{action.description}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-white">{action.label}</p>
|
||||
{action.badge ? (
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-100">
|
||||
{action.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
66
resources/js/dashboard/components/RecentAchievements.jsx
Normal file
66
resources/js/dashboard/components/RecentAchievements.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import AchievementBadge from '../../components/achievements/AchievementBadge'
|
||||
|
||||
export default function RecentAchievements() {
|
||||
const [data, setData] = useState({ recent: [], counts: { unlocked: 0, total: 0 } })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await window.axios.get('/api/user/achievements')
|
||||
if (!cancelled && response.data) {
|
||||
setData(response.data)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Achievements</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Recent Unlocks</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-gray-300">
|
||||
{data?.counts?.unlocked || 0} / {data?.counts?.total || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="mt-4 text-sm text-gray-400">Loading achievements...</p> : null}
|
||||
|
||||
{!loading && (!Array.isArray(data?.recent) || data.recent.length === 0) ? (
|
||||
<p className="mt-4 text-sm text-gray-400">No achievements unlocked yet.</p>
|
||||
) : null}
|
||||
|
||||
{!loading && Array.isArray(data?.recent) && data.recent.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{data.recent.map((achievement) => (
|
||||
<article key={achievement.id} className="rounded-xl border border-gray-700 bg-gray-900/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">{achievement.name}</p>
|
||||
<p className="mt-1 text-xs text-gray-400">{achievement.description}</p>
|
||||
</div>
|
||||
<AchievementBadge achievement={achievement} compact />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import LevelBadge from '../../components/xp/LevelBadge'
|
||||
|
||||
export default function RecommendedCreators() {
|
||||
const [items, setItems] = useState([])
|
||||
@@ -59,6 +60,9 @@ export default function RecommendedCreators() {
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{creator.username ? `@${creator.username}` : creator.name}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<LevelBadge level={creator.level} rank={creator.rank} compact />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{creator.followers_count} followers</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
76
resources/js/dashboard/components/TopCreatorsWidget.jsx
Normal file
76
resources/js/dashboard/components/TopCreatorsWidget.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import LevelBadge from '../../components/xp/LevelBadge'
|
||||
|
||||
export default function TopCreatorsWidget() {
|
||||
const [data, setData] = useState({ items: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await window.axios.get('/api/leaderboard/creators?period=weekly')
|
||||
if (!cancelled && response.data) {
|
||||
setData(response.data)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const items = Array.isArray(data?.items) ? data.items.slice(0, 5) : []
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Leaderboard</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Top Creators</h2>
|
||||
</div>
|
||||
<a href="/leaderboard?type=creators&period=weekly" className="text-xs font-semibold uppercase tracking-[0.14em] text-sky-300 hover:text-sky-200">
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="mt-4 text-sm text-gray-400">Loading leaderboard...</p> : null}
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-gray-400">No creators ranked yet.</p>
|
||||
) : null}
|
||||
|
||||
{!loading && items.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.map((item) => {
|
||||
const entity = item.entity || {}
|
||||
|
||||
return (
|
||||
<a key={item.rank} href={entity.url || '#'} className="flex items-center gap-3 rounded-xl border border-gray-700 bg-gray-900/60 p-3 transition hover:border-sky-400/40">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-sm font-black text-white">
|
||||
#{item.rank}
|
||||
</div>
|
||||
{entity.avatar ? <img src={entity.avatar} alt={entity.name || 'Creator'} className="h-11 w-11 rounded-xl object-cover" loading="lazy" /> : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{entity.name}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<LevelBadge level={entity.level} rank={entity.rank} compact />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-sky-300">{Math.round(item.score)}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import LevelBadge from '../../components/xp/LevelBadge'
|
||||
|
||||
export default function TrendingArtworks() {
|
||||
const [items, setItems] = useState([])
|
||||
@@ -58,6 +59,14 @@ export default function TrendingArtworks() {
|
||||
/>
|
||||
<div className="p-2">
|
||||
<p className="line-clamp-1 text-sm font-semibold text-white">{item.title}</p>
|
||||
{item.creator ? (
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs text-gray-400">
|
||||
{item.creator.username ? `@${item.creator.username}` : item.creator.name}
|
||||
</span>
|
||||
<LevelBadge level={item.creator.level} rank={item.creator.rank} compact />
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{item.likes} likes • {item.views} views
|
||||
</p>
|
||||
|
||||
63
resources/js/dashboard/components/XPProgressWidget.jsx
Normal file
63
resources/js/dashboard/components/XPProgressWidget.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import LevelBadge from '../../components/xp/LevelBadge'
|
||||
import XPProgressBar from '../../components/xp/XPProgressBar'
|
||||
|
||||
export default function XPProgressWidget({ initialLevel = 1, initialRank = 'Newbie' }) {
|
||||
const [data, setData] = useState({
|
||||
xp: 0,
|
||||
level: initialLevel,
|
||||
rank: initialRank,
|
||||
current_level_xp: 0,
|
||||
next_level_xp: 100,
|
||||
progress_percent: 0,
|
||||
max_level: false,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await window.axios.get('/api/user/xp')
|
||||
if (!cancelled && response.data) {
|
||||
setData((current) => ({ ...current, ...response.data }))
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-gray-400">Progression</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">XP Progress</h2>
|
||||
</div>
|
||||
<LevelBadge level={data.level} rank={data.rank} compact />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<XPProgressBar
|
||||
xp={data.xp}
|
||||
currentLevelXp={data.current_level_xp}
|
||||
nextLevelXp={data.next_level_xp}
|
||||
progressPercent={data.progress_percent}
|
||||
maxLevel={data.max_level}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="mt-3 text-xs text-gray-400">Syncing your latest XP...</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,30 @@ import DashboardPage from './DashboardPage'
|
||||
|
||||
const rootElement = document.getElementById('dashboard-root')
|
||||
|
||||
function parseJsonObject(value) {
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement)
|
||||
root.render(
|
||||
<DashboardPage
|
||||
username={rootElement.dataset.username || 'Creator'}
|
||||
isCreator={rootElement.dataset.isCreator === '1'}
|
||||
level={Number(rootElement.dataset.level || 1)}
|
||||
rank={rootElement.dataset.rank || 'Newbie'}
|
||||
receivedCommentsCount={Number(rootElement.dataset.receivedCommentsCount || 0)}
|
||||
overview={parseJsonObject(rootElement.dataset.overview)}
|
||||
preferences={parseJsonObject(rootElement.dataset.preferences)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
18
resources/js/leaderboard.jsx
Normal file
18
resources/js/leaderboard.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
import LeaderboardPage from './Pages/Leaderboard/LeaderboardPage'
|
||||
|
||||
const pages = {
|
||||
'Leaderboard/LeaderboardPage': LeaderboardPage,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
},
|
||||
})
|
||||
@@ -16,6 +16,14 @@ if (!window.Alpine) {
|
||||
import './lib/nav-context.js';
|
||||
import { sendTagInteractionEvent } from './lib/tagAnalytics';
|
||||
|
||||
function safeParseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value || 'null') ?? fallback;
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function mountStoryEditor() {
|
||||
var storyEditorRoot = document.getElementById('story-editor-react-root');
|
||||
if (!storyEditorRoot) return;
|
||||
@@ -62,6 +70,176 @@ function mountStoryEditor() {
|
||||
|
||||
mountStoryEditor();
|
||||
|
||||
function mountToolbarNotifications() {
|
||||
var rootEl = document.getElementById('toolbar-notification-root');
|
||||
if (!rootEl || rootEl.dataset.reactMounted === 'true') return;
|
||||
|
||||
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
|
||||
rootEl.dataset.reactMounted = 'true';
|
||||
|
||||
void import('./components/social/NotificationDropdown.jsx')
|
||||
.then(function (module) {
|
||||
var Component = module.default;
|
||||
createRoot(rootEl).render(React.createElement(Component, props));
|
||||
})
|
||||
.catch(function () {
|
||||
rootEl.dataset.reactMounted = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
function mountStorySocial() {
|
||||
var socialRoot = document.getElementById('story-social-root');
|
||||
if (socialRoot && socialRoot.dataset.reactMounted !== 'true') {
|
||||
var props = safeParseJson(socialRoot.getAttribute('data-props'), {});
|
||||
socialRoot.dataset.reactMounted = 'true';
|
||||
|
||||
void import('./components/social/StorySocialPanel.jsx')
|
||||
.then(function (module) {
|
||||
var Component = module.default;
|
||||
createRoot(socialRoot).render(React.createElement(Component, {
|
||||
story: props.story,
|
||||
creator: props.creator,
|
||||
initialState: props.state,
|
||||
initialComments: props.comments,
|
||||
isAuthenticated: Boolean(props.is_authenticated),
|
||||
}));
|
||||
})
|
||||
.catch(function () {
|
||||
socialRoot.dataset.reactMounted = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
var followRoot = document.getElementById('story-creator-follow-root');
|
||||
if (!followRoot || followRoot.dataset.reactMounted === 'true') return;
|
||||
|
||||
var followProps = safeParseJson(followRoot.getAttribute('data-props'), {});
|
||||
followRoot.dataset.reactMounted = 'true';
|
||||
|
||||
void import('./components/social/FollowButton.jsx')
|
||||
.then(function (module) {
|
||||
var Component = module.default;
|
||||
createRoot(followRoot).render(React.createElement(Component, {
|
||||
username: followProps.username,
|
||||
initialFollowing: Boolean(followProps.following),
|
||||
initialCount: Number(followProps.followers_count || 0),
|
||||
className: 'w-full justify-center',
|
||||
}));
|
||||
})
|
||||
.catch(function () {
|
||||
followRoot.dataset.reactMounted = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
mountToolbarNotifications();
|
||||
mountStorySocial();
|
||||
|
||||
function initStorySyntaxHighlighting() {
|
||||
var codeBlocks = Array.prototype.slice.call(document.querySelectorAll('.story-prose pre code'));
|
||||
if (!codeBlocks.length) return;
|
||||
|
||||
function fallbackCopyText(text) {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.left = '-1000px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return document.execCommand('copy');
|
||||
} catch (_error) {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
return fallbackCopyText(text)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('Clipboard unavailable'));
|
||||
}
|
||||
|
||||
function attachCopyButton(block) {
|
||||
var pre = block.parentElement;
|
||||
if (!pre || pre.dataset.copyButtonMounted === 'true') return;
|
||||
|
||||
var button = document.createElement('button');
|
||||
var icon = document.createElement('span');
|
||||
var label = document.createElement('span');
|
||||
|
||||
button.type = 'button';
|
||||
button.className = 'story-code-copy-button';
|
||||
icon.className = 'story-code-copy-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '⧉';
|
||||
label.className = 'story-code-copy-label';
|
||||
label.textContent = 'Copy';
|
||||
button.appendChild(icon);
|
||||
button.appendChild(label);
|
||||
button.dataset.copied = 'idle';
|
||||
button.setAttribute('aria-label', 'Copy code block');
|
||||
|
||||
var resetTimer = 0;
|
||||
button.addEventListener('click', function () {
|
||||
var source = block.innerText || block.textContent || '';
|
||||
|
||||
copyText(source)
|
||||
.then(function () {
|
||||
icon.textContent = '✓';
|
||||
label.textContent = 'Copied';
|
||||
button.dataset.copied = 'true';
|
||||
})
|
||||
.catch(function () {
|
||||
icon.textContent = '!';
|
||||
label.textContent = 'Failed';
|
||||
button.dataset.copied = 'false';
|
||||
})
|
||||
.finally(function () {
|
||||
window.clearTimeout(resetTimer);
|
||||
resetTimer = window.setTimeout(function () {
|
||||
icon.textContent = '⧉';
|
||||
label.textContent = 'Copy';
|
||||
button.dataset.copied = 'idle';
|
||||
}, 1800);
|
||||
});
|
||||
});
|
||||
|
||||
pre.appendChild(button);
|
||||
pre.dataset.copyButtonMounted = 'true';
|
||||
}
|
||||
|
||||
void import('highlight.js/lib/common')
|
||||
.then(function (module) {
|
||||
var hljs = module.default;
|
||||
|
||||
codeBlocks.forEach(function (block) {
|
||||
attachCopyButton(block);
|
||||
|
||||
if (block.dataset.syntaxHighlighted === 'true') return;
|
||||
|
||||
var language = (block.getAttribute('data-language') || '').trim();
|
||||
if (language && !block.className.includes('language-')) {
|
||||
block.classList.add('language-' + language);
|
||||
}
|
||||
|
||||
hljs.highlightElement(block);
|
||||
block.dataset.syntaxHighlighted = 'true';
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
// Leave code blocks readable even if highlighting fails to load.
|
||||
});
|
||||
}
|
||||
|
||||
initStorySyntaxHighlighting();
|
||||
|
||||
function initTagsSearchAssist() {
|
||||
var roots = document.querySelectorAll('[data-tags-search-root]');
|
||||
if (!roots.length) return;
|
||||
|
||||
@@ -3,9 +3,11 @@ import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import ProfileShow from './Pages/Profile/ProfileShow'
|
||||
import ProfileGallery from './Pages/Profile/ProfileGallery'
|
||||
|
||||
const pages = {
|
||||
'Profile/ProfileShow': ProfileShow,
|
||||
'Profile/ProfileGallery': ProfileGallery,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
<nav class="navbar yamm navbar-skinbase navbar-fixed-top" role="navigation">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#mainToolbarNavCollapse">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a href="/" class="navbar-brand sb_toolbarLogo" title="SkinBase">SkinBase</a>
|
||||
</div>
|
||||
|
||||
<div class="collapse navbar-collapse" id="mainToolbarNavCollapse">
|
||||
<form class="hidden-xs navbar-form navbar-left" action="/search" method="get" id="search_box">
|
||||
<input type="text" name="q" value="{{ request('q') }}">
|
||||
<input type="hidden" name="group" value="all">
|
||||
<button type="submit"><i class="fa fa-search fa-fw"></i></button>
|
||||
</form>
|
||||
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown yamm-fw">
|
||||
<a href="#" class="dropdown-toggle c-white" data-toggle="dropdown" data-hover="dropdown" data-close-others="true">
|
||||
<i class="fa fa-cloud"></i> Browse <i class="fa fa-angle-down"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<div class="yamm-content">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3 col-md-3 menu_box">
|
||||
<i class="fa fa-archive fa-fw"></i> Browse Artworks:<br>
|
||||
<div class="divider"></div>
|
||||
<ul class="submenu">
|
||||
<li><a href="/browse"><i class="fa fa-cloud fa-fw"></i> All Artworks</a></li>
|
||||
<li><a href="/photography"><i class="fa fa-photo fa-fw"></i> Photography</a></li>
|
||||
<li><a href="/wallpapers"><i class="fa fa-photo fa-fw"></i> Wallpapers</a></li>
|
||||
<li><a href="/skins"><i class="fa fa-photo fa-fw"></i> Skins</a></li>
|
||||
<li><a href="/other"><i class="fa fa-photo fa-fw"></i> Other</a></li>
|
||||
<li><a href="/featured-artworks"><i class="fa fa-trophy fa-fw"></i> Featured</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6 col-sm-3 col-md-3">
|
||||
<i class="fa fa-eye fa-fw"></i> View:<br>
|
||||
<div class="divider"></div>
|
||||
<ul class="submenu">
|
||||
<li><a href="/forum"><i class="fa fa-comments fa-fw"></i> Forum</a></li>
|
||||
<li><a href="{{ route('community.chat') }}"><i class="fa fa-comments fa-fw"></i> Chat</a></li>
|
||||
<li><a href="/community/activity"><i class="fa fa-wave-square fa-fw"></i> Activity Feed</a></li>
|
||||
<li><a href="/browse-categories"><i class="fa fa-cloud fa-fw"></i> Categories</a></li>
|
||||
<li><a href="/latest-artworks"><i class="fa fa-trophy fa-fw"></i> Latest Uploads</a></li>
|
||||
<li><a href="/daily-uploads"><i class="fa fa-trophy fa-fw"></i> Recent Uploads</a></li>
|
||||
<li><a href="/today-in-history"><i class="fa fa-trophy fa-fw"></i> Today in History</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6 col-sm-3 col-md-3">
|
||||
<i class="fa fa-users fa-fw"></i> Authors:<br>
|
||||
<div class="divider"></div>
|
||||
<ul class="submenu">
|
||||
<li><a href="/interviews"><i class="fa fa-users fa-fw"></i> Interviews</a></li>
|
||||
<li><a href="/Members/MembersPhotos/545"><i class="fa fa-users fa-fw"></i> Members Photos</a></li>
|
||||
<li><a href="/top-authors"><i class="fa fa-users fa-fw"></i> Top Authors</a></li>
|
||||
<li><a href="/community/activity"><i class="fa fa-wave-square fa-fw"></i> Activity Feed</a></li>
|
||||
<li><a href="/monthly-commentators"><i class="fa fa-bar-chart fa-fw"></i> Monthly Top Comments</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-xs-6 col-sm-3 col-md-3">
|
||||
<i class="fa fa-bar-chart fa-fw"></i> Statistics:<br>
|
||||
<div class="divider"></div>
|
||||
<ul class="submenu">
|
||||
<li><a href="/today-downloads"><i class="fa fa-bar-chart fa-fw"></i> Today Downloads</a></li>
|
||||
<li><a href="/top-favourites"><i class="fa fa-bar-chart fa-fw"></i> Top Favourites</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle c-white" data-toggle="dropdown" data-hover="dropdown" data-close-others="true">
|
||||
<i class="fa fa-list-ul"></i> Categories <i class="fa fa-angle-down"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="/photography"><i class="fa fa-camera"></i> Photography</a></li>
|
||||
<li><a href="/wallpapers"><i class="fa fa-desktop"></i> Wallpapers</a></li>
|
||||
<li><a href="/skins"><i class="fa fa-eye"></i> Skins</a></li>
|
||||
<li><a href="/other"><i class="fa fa-file-o"></i> Others</a></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li><a href="/browse-categories" class="btn_category"><i class="fa fa-list"></i> Categories List</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
@auth
|
||||
@php
|
||||
$userId = auth()->id();
|
||||
try {
|
||||
$uploadCount = \Illuminate\Support\Facades\DB::table('artworks')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$uploadCount = 0;
|
||||
}
|
||||
try {
|
||||
$favCount = \Illuminate\Support\Facades\DB::table('artwork_favourites')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$favCount = 0;
|
||||
}
|
||||
try {
|
||||
$msgCount = \Illuminate\Support\Facades\DB::table('messages')->where('reciever_id', $userId)->whereNull('read_at')->count();
|
||||
} catch (\Throwable $e) {
|
||||
$msgCount = 0;
|
||||
}
|
||||
try {
|
||||
$noticeCount = \Illuminate\Support\Facades\DB::table('notification')->where('user_id', $userId)->where('new', 1)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$noticeCount = 0;
|
||||
}
|
||||
try {
|
||||
$profile = \Illuminate\Support\Facades\DB::table('user_profiles')->where('user_id', $userId)->first();
|
||||
$avatarHash = $profile->avatar_hash ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$avatarHash = null;
|
||||
}
|
||||
$displayName = auth()->user()->name ?: (auth()->user()->username ?? '');
|
||||
@endphp
|
||||
|
||||
<li class="hidden-xs hidden-sm menu_notice">
|
||||
<a href="/upload" title="Upload new Artwork"><i class="fa fa-upload fa-fw"></i><br> {{ $uploadCount }}</a>
|
||||
</li>
|
||||
<li class="hidden-xs hidden-sm menu_notice">
|
||||
<a href="/favourites/{{ $userId }}/{{ auth()->user()->username ?? '' }}" title="Your Favourite Artworks"><i class="fa fa-heart fa-fw"></i><br> {{ $favCount }}</a>
|
||||
</li>
|
||||
<li class="hidden-xs hidden-sm menu_notice">
|
||||
<a href="/messages" title="Messages"><i class="fa fa-envelope fa-fw"></i><br> {{ $msgCount }}</a>
|
||||
</li>
|
||||
<li class="hidden-xs hidden-sm menu_notice">
|
||||
<a href="/notices" title="Notices"><i class="fa fa-bell"></i><br> {{ $noticeCount }}</a>
|
||||
</li>
|
||||
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle c-white" data-toggle="dropdown" data-hover="dropdown" data-close-others="true">
|
||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) $userId, $avatarHash ?? null, 32) }}" alt="{{ $displayName }}" width="18">
|
||||
<span class="username">{{ $displayName }}</span>
|
||||
<i class="fa fa-angle-down"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="/upload"><i class="fa fa-upload"></i> Upload</a></li>
|
||||
<li><a href="/studio/artworks"><i class="fa fa-palette"></i> Studio</a></li>
|
||||
<li><a href="{{ route('dashboard.artworks.index') }}"><i class="fa fa-cloud"></i> Edit Artworks</a></li>
|
||||
<li role="presentation" class="divider"></li>
|
||||
<li><a href="/statistics"><i class="fa fa-cog"></i> Statistics</a></li>
|
||||
<li><a href="/mybuddies.php"><i class="fa fa-cog"></i> My Followes</a></li>
|
||||
<li><a href="/buddies.php"><i class="fa fa-cog"></i> Who follows me</a></li>
|
||||
<li role="presentation" class="divider"></li>
|
||||
<li><a href="/recieved-comments"><i class="fa fa-cog"></i> Received Comments</a></li>
|
||||
<li><a href="/favourites/{{ $userId }}/{{ auth()->user()->username ?? '' }}"><i class="fa fa-cog"></i> My Favourites</a></li>
|
||||
<li><a href="/gallery/{{ $userId }}/{{ auth()->user()->username ?? '' }}"><i class="fa fa-cog"></i> My Gallery</a></li>
|
||||
<li role="presentation" class="divider"></li>
|
||||
<li><a href="/user"><i class="fa fa-cog"></i> Edit Profile</a></li>
|
||||
<li><a href="/profile/{{ $userId }}/{{ auth()->user()->username ?? '' }}"><i class="fa fa-cog"></i> View My Profile</a></li>
|
||||
<li class="dropdown-footer clearfix">
|
||||
<form method="POST" action="/logout" style="margin:0;">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-link" style="padding: 3px 20px;"> <i class="fa fa-power-off"></i> Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="hidden-xs hidden-sm menu_chat">
|
||||
<span class="toggle_menu" title="Chat"><i class="fa fa-weixin fa-lg fa-fw"></i></span>
|
||||
</li>
|
||||
@else
|
||||
<li class="dropdown"><a href="/signup" title="Signup for a new account" class="c-white"><i class="fa fa-unlock fa-fw"></i> Join</a></li>
|
||||
<li class="dropdown"><a href="/login" title="Login to Skinbase account" class="c-white"><i class="fa fa-sign-in fa-fw"></i> Sign in</a></li>
|
||||
@endauth
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,57 +0,0 @@
|
||||
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">{{ $page_title ?? 'My Buddies' }}</h1>
|
||||
<p>List of members you are following</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="icon-grid">
|
||||
@forelse($buddies as $b)
|
||||
@php
|
||||
$icon = $b->icon ?? 'default.jpg';
|
||||
$uname = $b->uname ?? 'Unknown';
|
||||
$friendId = $b->friend_id ?? $b->friendId ?? null;
|
||||
@endphp
|
||||
|
||||
@php $buddyUrl = ($b->user_username ?? null) ? '/@' . $b->user_username : '/profile/' . $friendId; @endphp
|
||||
<div class="icon-flex">
|
||||
<div>
|
||||
<a href="{{ $buddyUrl }}">
|
||||
<h4>{{ $uname }}</h4>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="{{ $buddyUrl }}">
|
||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) $friendId, null, 50) }}" alt="{{ $uname }}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if(auth()->check() && auth()->id() == ($b->user_id ?? null))
|
||||
<div>
|
||||
<form method="POST" action="{{ route('legacy.mybuddies.delete', ['id' => $b->id]) }}" onsubmit="return confirm('Really Remove From Friends List: {{ addslashes($uname) }}?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="btn btn-link" type="submit"><img src="/gfx/icon_delete.gif" alt="remove"></button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@empty
|
||||
<p>No buddies yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if(method_exists($buddies, 'links'))
|
||||
<div class="mt-3">{{ $buddies->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -353,7 +353,7 @@
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-images fa-fw text-[--sb-blue]"></i>
|
||||
Newest Artworks
|
||||
<a href="/gallery/{{ $user->id }}/{{ \Illuminate\Support\Str::slug($uname) }}"
|
||||
<a href="{{ !empty($user->username) ? route('profile.gallery', ['username' => strtolower((string) $user->username)]) : url('/gallery/'.$user->id.'/'.\Illuminate\Support\Str::slug($uname)) }}"
|
||||
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
|
||||
View Gallery <i class="fa-solid fa-arrow-right fa-fw"></i>
|
||||
</a>
|
||||
|
||||
108
resources/views/admin/countries/cpad.blade.php
Normal file
108
resources/views/admin/countries/cpad.blade.php
Normal file
@@ -0,0 +1,108 @@
|
||||
@extends('admin::layout.default')
|
||||
|
||||
@section('content')
|
||||
<x-page-layout>
|
||||
@include('admin::blocks.notification_error')
|
||||
|
||||
@if(session('msg_success'))
|
||||
<div class="alert alert-success alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||
{{ session('msg_success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<h3 class="mb-1">Countries</h3>
|
||||
<p class="text-muted mb-0">Read-only ISO country catalog with manual sync support.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-right">
|
||||
<form method="POST" action="{{ route('admin.cp.countries.sync') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-rotate"></i> Sync countries
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<form method="GET" action="{{ route('admin.cp.countries.main') }}" class="form-inline">
|
||||
<div class="input-group input-group-sm" style="max-width: 420px; width: 100%;">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search by code or name"
|
||||
class="form-control"
|
||||
/>
|
||||
<div class="input-group-append">
|
||||
<button type="submit" class="btn btn-default">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>ISO2</th>
|
||||
<th>ISO3</th>
|
||||
<th>Region</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($countries as $country)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center" style="gap: 10px;">
|
||||
@if ($country->local_flag_path)
|
||||
<img
|
||||
src="{{ $country->local_flag_path }}"
|
||||
alt="{{ $country->name_common }}"
|
||||
style="width: 24px; height: 16px; object-fit: cover; border-radius: 2px;"
|
||||
onerror="this.style.display='none'"
|
||||
>
|
||||
@endif
|
||||
<div>
|
||||
<div>{{ $country->name_common }}</div>
|
||||
@if ($country->name_official)
|
||||
<small class="text-muted">{{ $country->name_official }}</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>{{ $country->iso2 }}</code></td>
|
||||
<td><code>{{ $country->iso3 ?? '—' }}</code></td>
|
||||
<td>{{ $country->region ?? '—' }}</td>
|
||||
<td>
|
||||
@if ($country->active)
|
||||
<span class="badge badge-success">Active</span>
|
||||
@else
|
||||
<span class="badge badge-secondary">Inactive</span>
|
||||
@endif
|
||||
|
||||
@if ($country->is_featured)
|
||||
<span class="badge badge-info">Featured</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">No countries found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card-footer clearfix">
|
||||
{{ $countries->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</x-page-layout>
|
||||
@endsection
|
||||
99
resources/views/admin/countries/index.blade.php
Normal file
99
resources/views/admin/countries/index.blade.php
Normal file
@@ -0,0 +1,99 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-100">Countries</h1>
|
||||
<p class="mt-1 text-sm text-gray-400">Read-only ISO country catalog with manual sync support.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('admin.countries.sync') }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">
|
||||
Sync countries
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mb-4 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="get" action="{{ route('admin.countries.index') }}" class="mb-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search by code or name"
|
||||
class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100 placeholder:text-gray-500 md:max-w-sm"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-900">
|
||||
<table class="min-w-full divide-y divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-800 text-gray-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Country</th>
|
||||
<th class="px-4 py-3 text-left">ISO2</th>
|
||||
<th class="px-4 py-3 text-left">ISO3</th>
|
||||
<th class="px-4 py-3 text-left">Region</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800 text-gray-200">
|
||||
@forelse ($countries as $country)
|
||||
<tr>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($country->local_flag_path)
|
||||
<img
|
||||
src="{{ $country->local_flag_path }}"
|
||||
alt="{{ $country->name_common }}"
|
||||
class="h-4 w-6 rounded-sm object-cover"
|
||||
onerror="this.style.display='none'"
|
||||
>
|
||||
@endif
|
||||
<div>
|
||||
<div>{{ $country->name_common }}</div>
|
||||
@if ($country->name_official)
|
||||
<div class="text-xs text-gray-500">{{ $country->name_official }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $country->iso2 }}</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $country->iso3 ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $country->region ?? '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs {{ $country->active ? 'bg-emerald-500/10 text-emerald-200' : 'bg-gray-700 text-gray-300' }}">
|
||||
{{ $country->active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
@if ($country->is_featured)
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-xs text-sky-200">Featured</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center text-gray-400">No countries found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $countries->links() }}</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -4,11 +4,18 @@
|
||||
@vite(['resources/js/dashboard/index.jsx'])
|
||||
@endpush
|
||||
|
||||
@section('main-class', 'pt-20')
|
||||
|
||||
@section('content')
|
||||
<div
|
||||
id="dashboard-root"
|
||||
data-username="{{ $dashboard_user_name }}"
|
||||
data-is-creator="{{ $dashboard_is_creator ? '1' : '0' }}"
|
||||
data-level="{{ $dashboard_level ?? 1 }}"
|
||||
data-rank="{{ $dashboard_rank ?? 'Newbie' }}"
|
||||
data-received-comments-count="{{ (int) ($dashboard_received_comments_count ?? 0) }}"
|
||||
data-overview='@json($dashboard_overview ?? [])'
|
||||
data-preferences='@json($dashboard_preferences ?? [])'
|
||||
></div>
|
||||
|
||||
@if (session('status'))
|
||||
|
||||
@@ -3,27 +3,241 @@
|
||||
@section('content')
|
||||
<x-nova-page-header
|
||||
section="Dashboard"
|
||||
title="Comments"
|
||||
title="Received Comments"
|
||||
icon="fa-comments"
|
||||
:breadcrumbs="collect([
|
||||
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||
(object) ['name' => 'Comments', 'url' => route('dashboard.comments')],
|
||||
(object) ['name' => 'Comments', 'url' => route('dashboard.comments.received')],
|
||||
])"
|
||||
description="Comments across your dashboard activity and conversations."
|
||||
/>
|
||||
description="A clean inbox for feedback on your artworks, with quick scanning, filtering, and direct artwork context."
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="{{ route('community.activity') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-wave-square text-xs"></i>
|
||||
Community activity
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<div class="px-6 pb-16 pt-8 md:px-10">
|
||||
@if(empty($comments))
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-sm text-white/40">No comments to show.</p>
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2 rounded-2xl border border-white/[0.06] bg-white/[0.025] p-2 shadow-[0_12px_30px_rgba(0,0,0,0.14)]">
|
||||
<a href="{{ route('dashboard.comments.received') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition {{ request()->routeIs('dashboard.comments.received') ? 'bg-sky-500/15 text-sky-200 ring-1 ring-sky-400/20' : 'text-white/60 hover:bg-white/[0.05] hover:text-white' }}">
|
||||
<i class="fa-solid fa-inbox"></i>
|
||||
Received
|
||||
</a>
|
||||
<a href="{{ route('messages.index') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.05] hover:text-white">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
Messages
|
||||
</a>
|
||||
<a href="{{ route('dashboard.notifications') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.05] hover:text-white">
|
||||
<i class="fa-solid fa-bell"></i>
|
||||
Notifications
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if(($freshlyClearedCount ?? 0) > 0)
|
||||
<div class="mb-6 rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-5 py-4 text-sm text-emerald-100 shadow-[0_12px_30px_rgba(16,185,129,0.12)]">
|
||||
Marked {{ number_format((int) $freshlyClearedCount) }} new {{ (int) $freshlyClearedCount === 1 ? 'comment' : 'comments' }} as read when you opened this inbox.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-sky-400/20 bg-sky-500/10 p-5 shadow-[0_18px_45px_rgba(10,132,255,0.10)]">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-200/75">Total comments</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($stats['total'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-sky-100/70">All comments on your approved artworks.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-200/75">Last 7 days</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($stats['recent'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-emerald-100/70">Recent conversation momentum.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-violet-400/20 bg-violet-500/10 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-200/75">Unique commenters</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($stats['commenters'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-violet-100/70">Distinct people engaging with your work.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-amber-400/20 bg-amber-500/10 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-amber-200/75">Active artworks</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($stats['artworks'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-amber-100/70">Artworks that received comments.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-[26px] border border-white/[0.07] bg-white/[0.03] p-4 shadow-[0_18px_45px_rgba(0,0,0,0.18)] md:p-5">
|
||||
<form method="GET" action="{{ route('dashboard.comments.received') }}" class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-1 flex-col gap-3 sm:flex-row">
|
||||
<label class="relative flex-1">
|
||||
<span class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/30">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search comment text, artwork title, or username"
|
||||
class="w-full rounded-2xl border border-white/[0.08] bg-black/20 py-3 pl-11 pr-4 text-sm text-white placeholder:text-white/30 outline-none transition focus:border-sky-400/40 focus:bg-black/30"
|
||||
>
|
||||
</label>
|
||||
|
||||
<select
|
||||
name="sort"
|
||||
class="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40 focus:bg-black/30"
|
||||
>
|
||||
<option value="newest" @selected($sort === 'newest')>Newest first</option>
|
||||
<option value="oldest" @selected($sort === 'oldest')>Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-2xl bg-sky-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-sky-400"
|
||||
>
|
||||
<i class="fa-solid fa-sliders"></i>
|
||||
Apply
|
||||
</button>
|
||||
|
||||
@if($search !== '' || $sort !== 'newest')
|
||||
<a
|
||||
href="{{ route('dashboard.comments.received') }}"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 text-sm font-medium text-white/65 transition hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
Reset
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if($comments->isEmpty())
|
||||
<div class="rounded-[28px] border border-white/[0.07] bg-white/[0.025] px-8 py-16 text-center shadow-[0_18px_45px_rgba(0,0,0,0.18)]">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.03] text-white/35">
|
||||
<i class="fa-regular fa-comments text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="mt-5 text-2xl font-semibold text-white">No comments found</h2>
|
||||
<p class="mx-auto mt-3 max-w-xl text-sm leading-6 text-white/45">
|
||||
@if($search !== '')
|
||||
Nothing matched your current search. Try a shorter phrase, a username, or reset the filters.
|
||||
@else
|
||||
When members comment on your artworks, they will appear here with artwork previews and quick context.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] p-6">
|
||||
<ul class="space-y-2">
|
||||
@foreach($comments as $c)
|
||||
<li class="text-sm text-white/75">{{ $c }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<div class="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
@foreach($comments as $comment)
|
||||
@php
|
||||
$author = $comment->user;
|
||||
$artwork = $comment->artwork;
|
||||
$profileUrl = !empty($author?->username)
|
||||
? '/@' . $author->username
|
||||
: '/profile/' . (int) ($author?->id ?? 0);
|
||||
$artworkUrl = $artwork
|
||||
? '/art/' . (int) $artwork->id . '/' . \Illuminate\Support\Str::slug($artwork->title ?? $artwork->name ?? 'artwork')
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<article class="group flex h-full flex-col overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.025] shadow-[0_18px_45px_rgba(0,0,0,0.18)] transition hover:border-sky-400/20 hover:bg-white/[0.035]">
|
||||
<div class="border-b border-white/[0.06] bg-black/20">
|
||||
@if($artwork)
|
||||
<a href="{{ $artworkUrl }}" class="block">
|
||||
<img
|
||||
src="{{ $artwork->thumb_url ?? $artwork->thumb }}"
|
||||
alt="{{ $artwork->title ?? $artwork->name ?? 'Artwork' }}"
|
||||
class="h-52 w-full object-cover transition duration-500 group-hover:scale-[1.02]"
|
||||
>
|
||||
</a>
|
||||
@else
|
||||
<div class="flex h-52 items-center justify-center text-white/25">
|
||||
<i class="fa-regular fa-image text-4xl"></i>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col p-5 md:p-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<a href="{{ $profileUrl }}" class="shrink-0">
|
||||
<img
|
||||
src="{{ \App\Support\AvatarUrl::forUser((int) ($author?->id ?? 0), $author?->profile?->avatar_hash, 64) }}"
|
||||
alt="{{ $author?->name ?? $author?->username ?? 'Member' }}"
|
||||
class="h-12 w-12 rounded-full object-cover ring-1 ring-white/[0.10]"
|
||||
>
|
||||
</a>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<a href="{{ $profileUrl }}" class="truncate text-base font-semibold text-white transition hover:text-sky-300">
|
||||
{{ $author?->name ?? $author?->username ?? 'Unknown member' }}
|
||||
</a>
|
||||
@if(!empty($author?->username))
|
||||
<span class="truncate text-xs font-medium uppercase tracking-[0.18em] text-white/30">{{ '@' . $author->username }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-white/45">
|
||||
{{ optional($comment->created_at)->diffForHumans() ?? 'Unknown time' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-white/60">
|
||||
<i class="fa-solid fa-message"></i>
|
||||
Comment #{{ $comment->id }}
|
||||
</span>
|
||||
@if($artworkUrl)
|
||||
<a href="{{ $artworkUrl }}" class="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-sky-200 transition hover:bg-sky-500/20">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square"></i>
|
||||
Open artwork
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-white/[0.06] bg-black/20 p-4 md:p-5">
|
||||
<div class="prose prose-invert prose-sm max-w-none prose-p:text-white/80 prose-a:text-sky-300 prose-strong:text-white prose-code:text-sky-200">
|
||||
{!! $comment->getDisplayHtml() !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-5">
|
||||
<div class="flex flex-col gap-3 border-t border-white/[0.06] pt-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-white/30">Artwork</p>
|
||||
@if($artwork)
|
||||
<a href="{{ $artworkUrl }}" class="mt-1 block truncate text-sm font-medium text-white/80 transition hover:text-sky-300">
|
||||
{{ $artwork->title ?? $artwork->name ?? 'Untitled artwork' }}
|
||||
</a>
|
||||
@else
|
||||
<p class="mt-1 text-sm text-white/35">This artwork is no longer available.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-white/45">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.03] px-3 py-1">
|
||||
<i class="fa-regular fa-clock"></i>
|
||||
{{ optional($comment->created_at)->format('M j, Y H:i') ?? 'Unknown date' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
{{ $comments->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
187
resources/views/dashboard/notifications.blade.php
Normal file
187
resources/views/dashboard/notifications.blade.php
Normal file
@@ -0,0 +1,187 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<x-nova-page-header
|
||||
section="Dashboard"
|
||||
title="Notifications"
|
||||
icon="fa-bell"
|
||||
:breadcrumbs="collect([
|
||||
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||
(object) ['name' => 'Notifications', 'url' => route('dashboard.notifications')],
|
||||
])"
|
||||
description="A dedicated feed for follows, likes, comments, and system events so you can triage account activity quickly."
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<form id="mark-all-notifications-read-form" class="contents">
|
||||
<button
|
||||
type="button"
|
||||
id="mark-all-notifications-read"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i class="fa-solid fa-check-double text-xs"></i>
|
||||
Mark all read
|
||||
</button>
|
||||
</form>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<div class="px-6 pb-16 pt-8 md:px-10">
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2 rounded-2xl border border-white/[0.06] bg-white/[0.025] p-2 shadow-[0_12px_30px_rgba(0,0,0,0.14)]">
|
||||
<a href="{{ route('dashboard.comments.received') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.05] hover:text-white">
|
||||
<i class="fa-solid fa-inbox"></i>
|
||||
Received
|
||||
</a>
|
||||
<a href="{{ route('messages.index') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.05] hover:text-white">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
Messages
|
||||
</a>
|
||||
<a href="{{ route('dashboard.notifications') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition {{ request()->routeIs('dashboard.notifications') ? 'bg-sky-500/15 text-sky-200 ring-1 ring-sky-400/20' : 'text-white/60 hover:bg-white/[0.05] hover:text-white' }}">
|
||||
<i class="fa-solid fa-bell"></i>
|
||||
Notifications
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-2xl border border-sky-400/20 bg-sky-500/10 p-5 shadow-[0_18px_45px_rgba(10,132,255,0.10)]">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-200/75">Unread</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($unreadCount) }}</p>
|
||||
<p class="mt-2 text-sm text-sky-100/70">Notifications that still need attention.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-400/20 bg-violet-500/10 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-200/75">Loaded</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($notifications->count()) }}</p>
|
||||
<p class="mt-2 text-sm text-violet-100/70">Items shown on this page.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-400/20 bg-amber-500/10 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-amber-200/75">Total feed</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($notificationsMeta['total'] ?? 0)) }}</p>
|
||||
<p class="mt-2 text-sm text-amber-100/70">All notifications stored for your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($notifications->isEmpty())
|
||||
<div class="rounded-[28px] border border-white/[0.07] bg-white/[0.025] px-8 py-16 text-center shadow-[0_18px_45px_rgba(0,0,0,0.18)]">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.03] text-white/35">
|
||||
<i class="fa-regular fa-bell text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="mt-5 text-2xl font-semibold text-white">No notifications yet</h2>
|
||||
<p class="mx-auto mt-3 max-w-xl text-sm leading-6 text-white/45">
|
||||
Follows, comments, likes, and system notices will appear here as your account becomes more active.
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@foreach($notifications as $item)
|
||||
@php
|
||||
$isUnread = !($item['read'] ?? false);
|
||||
$actor = $item['actor'] ?? null;
|
||||
$destination = $item['url'] ?: route('dashboard.comments.received');
|
||||
@endphp
|
||||
<a
|
||||
href="{{ $destination }}"
|
||||
class="block rounded-[24px] border p-5 transition hover:bg-white/[0.04] {{ $isUnread ? 'border-sky-400/20 bg-sky-500/[0.08]' : 'border-white/[0.07] bg-white/[0.025]' }}"
|
||||
data-notification-link
|
||||
data-notification-id="{{ $item['id'] }}"
|
||||
data-notification-read="{{ ($item['read'] ?? false) ? '1' : '0' }}"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
src="{{ $actor['avatar_url'] ?? 'https://files.skinbase.org/default/avatar_default.webp' }}"
|
||||
alt="{{ $actor['name'] ?? 'Notification' }}"
|
||||
class="h-12 w-12 rounded-full object-cover ring-1 ring-white/10"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-white/90">{{ $item['message'] ?? 'New activity' }}</p>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.16em] text-white/30">
|
||||
<span>{{ $item['type'] ?? 'notification' }}</span>
|
||||
<span>{{ $item['time_ago'] ?? '' }}</span>
|
||||
@if(!empty($actor['username']))
|
||||
<span>{{ '@' . $actor['username'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if($isUnread)
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border border-sky-400/25 bg-sky-500/12 px-3 py-1 text-xs font-semibold text-sky-200">
|
||||
<i class="fa-solid fa-circle text-[8px]"></i>
|
||||
Unread
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs font-semibold text-white/50">
|
||||
Read
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if(($notificationsMeta['last_page'] ?? 1) > 1)
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-2">
|
||||
@for($page = 1; $page <= (int) ($notificationsMeta['last_page'] ?? 1); $page++)
|
||||
<a
|
||||
href="{{ route('dashboard.notifications', ['page' => $page]) }}"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center rounded-xl border px-3 text-sm font-medium transition {{ (int) ($notificationsMeta['current_page'] ?? 1) === $page ? 'border-sky-400/30 bg-sky-500/15 text-sky-200' : 'border-white/[0.08] bg-white/[0.03] text-white/60 hover:bg-white/[0.06] hover:text-white' }}"
|
||||
>
|
||||
{{ $page }}
|
||||
</a>
|
||||
@endfor
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||
var markAllButton = document.getElementById('mark-all-notifications-read');
|
||||
|
||||
async function postJson(url) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
}
|
||||
|
||||
if (markAllButton) {
|
||||
markAllButton.addEventListener('click', async function () {
|
||||
markAllButton.disabled = true;
|
||||
try {
|
||||
await postJson('/api/notifications/read-all');
|
||||
window.location.reload();
|
||||
} catch (_error) {
|
||||
markAllButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-notification-link]').forEach(function (link) {
|
||||
link.addEventListener('click', async function (event) {
|
||||
if (link.dataset.notificationRead === '1') return;
|
||||
|
||||
event.preventDefault();
|
||||
try {
|
||||
await postJson('/api/notifications/' + link.dataset.notificationId + '/read');
|
||||
} catch (_error) {
|
||||
// Navigate even if mark-read fails.
|
||||
}
|
||||
window.location.assign(link.href);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@endsection
|
||||
@@ -66,6 +66,9 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@stack('head')
|
||||
@if(isset($page) && is_array($page))
|
||||
@inertiaHead
|
||||
@endif
|
||||
|
||||
@if(config('services.google_adsense.publisher_id'))
|
||||
{{-- Google AdSense — consent-gated loader --}}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
$navSection = match(true) {
|
||||
request()->is('discover', 'discover/*') => 'discover',
|
||||
request()->is('browse', 'photography', 'wallpapers', 'skins', 'other', 'tags', 'tags/*') => 'browse',
|
||||
request()->is('creators', 'creators/*', 'stories', 'stories/*', 'following') => 'creators',
|
||||
request()->is('creators', 'creators/*', 'stories', 'stories/*', 'following', 'leaderboard') => 'creators',
|
||||
request()->is('forum', 'forum/*', 'news', 'news/*') => 'community',
|
||||
default => null,
|
||||
};
|
||||
@@ -59,6 +59,9 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/most-downloaded">
|
||||
<i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('downloads.today') }}">
|
||||
<i class="fa-solid fa-arrow-down-short-wide w-4 text-center text-sb-muted"></i>Today Downloads
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
|
||||
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
|
||||
</a>
|
||||
@@ -115,6 +118,9 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/creators/top">
|
||||
<i class="fa-solid fa-star w-4 text-center text-sb-muted"></i>Top Creators
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/leaderboard">
|
||||
<i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Leaderboard
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/creators/rising">
|
||||
<i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators
|
||||
</a>
|
||||
@@ -141,6 +147,16 @@
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('community.activity') }}">
|
||||
<i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed
|
||||
</a>
|
||||
@auth
|
||||
<a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}">
|
||||
<span class="flex items-center gap-3">
|
||||
<i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments
|
||||
</span>
|
||||
@if(($receivedCommentsCount ?? 0) > 0)
|
||||
<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endauth
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum">
|
||||
<i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum
|
||||
</a>
|
||||
@@ -197,17 +213,7 @@
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<a href="{{ route('dashboard.comments') }}"
|
||||
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||
title="Notifications">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
@if(($noticeCount ?? 0) > 0)
|
||||
<span class="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">{{ $noticeCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
<div id="toolbar-notification-root" data-props='@json(['initialUnreadCount' => (int) ($noticeCount ?? 0)])'></div>
|
||||
</div>
|
||||
|
||||
<!-- Profile dropdown -->
|
||||
@@ -266,6 +272,15 @@
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-heart text-xs text-sb-muted"></i></span>
|
||||
My Favorites
|
||||
</a>
|
||||
<a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}">
|
||||
<span class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-inbox text-xs text-sb-muted"></i></span>
|
||||
<span>Received Comments</span>
|
||||
</span>
|
||||
@if(($receivedCommentsCount ?? 0) > 0)
|
||||
<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<div class="border-t border-panel my-1"></div>
|
||||
|
||||
@@ -356,6 +371,7 @@
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/fresh"><i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('downloads.today') }}"><i class="fa-solid fa-arrow-down-short-wide w-4 text-center text-sb-muted"></i>Today Downloads</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
|
||||
@auth
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-yellow-400/70"></i>For You</a>
|
||||
@@ -385,6 +401,7 @@
|
||||
</button>
|
||||
<div id="mobileSectionCreators" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/top"><i class="fa-solid fa-star w-4 text-center text-sb-muted"></i>Top Creators</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/leaderboard"><i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Leaderboard</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a>
|
||||
@auth
|
||||
@@ -401,6 +418,9 @@
|
||||
</button>
|
||||
<div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('community.activity') }}"><i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed</a>
|
||||
@auth
|
||||
<a class="flex items-center justify-between gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.comments.received') }}"><span class="flex items-center gap-3"><i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments</span>@if(($receivedCommentsCount ?? 0) > 0)<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>@endif</a>
|
||||
@endauth
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>News</a>
|
||||
</div>
|
||||
|
||||
9
resources/views/leaderboard.blade.php
Normal file
9
resources/views/leaderboard.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@vite(['resources/js/leaderboard.jsx'])
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
@@ -7,6 +7,37 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<x-nova-page-header
|
||||
section="Dashboard"
|
||||
title="Messages"
|
||||
icon="fa-envelope"
|
||||
:breadcrumbs="collect([
|
||||
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||
(object) ['name' => 'Messages', 'url' => route('messages.index')],
|
||||
])"
|
||||
description="Direct messages and group conversations with creators, staff, and community members."
|
||||
/>
|
||||
|
||||
<div class="px-6 pt-8 md:px-10">
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2 rounded-2xl border border-white/[0.06] bg-white/[0.025] p-2 shadow-[0_12px_30px_rgba(0,0,0,0.14)]">
|
||||
<a href="{{ route('dashboard.comments.received') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.05] hover:text-white">
|
||||
<i class="fa-solid fa-inbox"></i>
|
||||
Received
|
||||
</a>
|
||||
<a href="{{ route('messages.index') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition {{ request()->routeIs('messages.index', 'messages.show') ? 'bg-sky-500/15 text-sky-200 ring-1 ring-sky-400/20' : 'text-white/60 hover:bg-white/[0.05] hover:text-white' }}">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
Messages
|
||||
</a>
|
||||
<a href="{{ route('dashboard.notifications') }}"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium text-white/60 transition hover:bg-white/[0.05] hover:text-white">
|
||||
<i class="fa-solid fa-bell"></i>
|
||||
Notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messages-root"
|
||||
data-user-id='@json(auth()->id())'
|
||||
data-username='@json(auth()->user()?->username)'
|
||||
|
||||
@@ -76,6 +76,21 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! empty($countries))
|
||||
<div>
|
||||
<x-input-label for="country_id" :value="__('Country')" />
|
||||
<select id="country_id" name="country_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">{{ __('Choose country') }}</option>
|
||||
@foreach ($countries as $country)
|
||||
<option value="{{ $country['id'] }}" @selected((string) old('country_id', $selectedCountryId ?? $user->country_id) === (string) $country['id'])>
|
||||
{{ $country['flag_emoji'] ? $country['flag_emoji'].' ' : '' }}{{ $country['name'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('country_id')" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Posts & Feed Settings --}}
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900">{{ __('Posts & Feed') }}</h3>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">{{ $page_title ?? 'Followers' }}</h1>
|
||||
<p>Members who follow you</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="icon-grid">
|
||||
@forelse($followers as $f)
|
||||
@php
|
||||
$icon = $f->icon ?? 'default.jpg';
|
||||
$uname = $f->uname ?? 'Unknown';
|
||||
$followerId = $f->user_id ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="icon-flex">
|
||||
<div>
|
||||
<a href="/profile/{{ $followerId }}/{{ Str::slug($uname) }}">
|
||||
<h4>{{ $uname }}</h4>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href="/profile/{{ $followerId }}/{{ Str::slug($uname) }}">
|
||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) $followerId, null, 50) }}" alt="{{ $uname }}">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p>No followers yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if(method_exists($followers, 'links'))
|
||||
<div class="mt-3">{{ $followers->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => $page_title ?? 'Monthly Top Commentators', 'url' => route('legacy.monthly_commentators')],
|
||||
(object) ['name' => $page_title ?? 'Monthly Top Commentators', 'url' => route('comments.monthly')],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => $page_title ?? 'Daily Uploads', 'url' => route('legacy.daily_uploads')],
|
||||
(object) ['name' => $page_title ?? 'Daily Uploads', 'url' => route('uploads.daily')],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
|
||||
@@ -2,8 +2,30 @@
|
||||
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => $page_title ?? 'Most Downloaded Today', 'url' => route('legacy.today_downloads')],
|
||||
(object) ['name' => $page_title ?? 'Most Downloaded Today', 'url' => route('downloads.today')],
|
||||
]);
|
||||
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
|
||||
'id' => $art->id,
|
||||
'name' => $art->name ?? null,
|
||||
'slug' => $art->slug ?? null,
|
||||
'url' => isset($art->id) ? '/art/' . $art->id . '/' . ($art->slug ?: \Illuminate\Support\Str::slug($art->name ?? 'artwork')) : '#',
|
||||
'thumb' => $art->thumb ?? $art->thumb_url ?? null,
|
||||
'thumb_url' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->username ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
'metric_badge' => ((int) ($art->num_downloads ?? 0)) > 0 ? [
|
||||
'label' => number_format((int) $art->num_downloads),
|
||||
'iconClass' => 'fa-solid fa-download text-[10px]',
|
||||
'className' => 'bg-emerald-500/14 text-emerald-200 ring-emerald-400/30',
|
||||
] : null,
|
||||
])->values();
|
||||
$galleryNextPageUrl = $artworks->nextPageUrl();
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
@@ -26,41 +48,14 @@
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pt-8 pb-16 md:px-10">
|
||||
@if ($artworks && $artworks->isNotEmpty())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
@php
|
||||
$card = (object)[
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? 'Artwork',
|
||||
'thumb' => $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'slug' => $art->slug ?? \Illuminate\Support\Str::slug($art->name ?? 'artwork'),
|
||||
];
|
||||
$downloads = (int) ($art->num_downloads ?? 0);
|
||||
@endphp
|
||||
|
||||
{{-- Wrap card to overlay download badge --}}
|
||||
<div class="relative">
|
||||
<x-artwork-card :art="$card" />
|
||||
@if ($downloads > 0)
|
||||
<div class="absolute top-2 left-2 z-40 pointer-events-none">
|
||||
<span class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold bg-black/60 text-emerald-300 backdrop-blur-sm ring-1 ring-emerald-500/30">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{{ number_format($downloads) }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks='@json($galleryArtworks)'
|
||||
data-gallery-type="today-downloads"
|
||||
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||
data-limit="{{ $artworks->perPage() }}"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<svg class="mx-auto mb-3 w-10 h-10 text-white/20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -73,3 +68,24 @@
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 2600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">Gallery: {{ $user->uname ?? $user->name ?? 'User' }}</h1>
|
||||
<p>{{ $user->name ?? '' }}</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>User Gallery</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="gallery-grid grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
@foreach($artworks as $art)
|
||||
<div class="thumb-card effect2">
|
||||
<x-artwork-card :art="$art" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>User</strong></div>
|
||||
<div class="panel-body">
|
||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) ($user->user_id ?? $user->id), null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname ?? $user->name }}">
|
||||
<h3>{{ $user->uname ?? $user->name }}</h3>
|
||||
<p>{{ $user->about_me ?? '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Simple pagination controls like legacy site --}}
|
||||
@php
|
||||
$pages = (int) ceil($total / $hits);
|
||||
@endphp
|
||||
@if($pages > 1)
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
@for($i=1;$i<=$pages;$i++)
|
||||
<li class="{{ $i == $page ? 'active' : '' }}"><a href="{{ url('/gallery/'.$user->id.'?page='.$i) }}">{{ $i }}</a></li>
|
||||
@endfor
|
||||
</ul>
|
||||
</nav>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -10,6 +10,29 @@
|
||||
$seoNext = (isset($latestUploads) && method_exists($latestUploads, 'hasMorePages') && $latestUploads->hasMorePages())
|
||||
? $seoBase . '?page=' . ($seoPage + 1)
|
||||
: null;
|
||||
$homeUploadsItems = collect(method_exists($latestUploads ?? null, 'items') ? $latestUploads->items() : ($latestUploads ?? []));
|
||||
$homeGalleryArtworks = $homeUploadsItems->map(fn ($upload) => [
|
||||
'id' => $upload->id ?? null,
|
||||
'name' => $upload->name ?? $upload->title ?? null,
|
||||
'slug' => $upload->slug ?? \Illuminate\Support\Str::slug($upload->name ?? $upload->title ?? 'artwork'),
|
||||
'url' => $upload->url ?? ((isset($upload->id) && $upload->id) ? '/art/' . $upload->id . '/' . ($upload->slug ?? \Illuminate\Support\Str::slug($upload->name ?? $upload->title ?? 'artwork')) : '#'),
|
||||
'thumb' => $upload->thumb ?? $upload->thumb_url ?? null,
|
||||
'thumb_url' => $upload->thumb_url ?? $upload->thumb ?? null,
|
||||
'thumb_srcset' => $upload->thumb_srcset ?? null,
|
||||
'uname' => $upload->uname ?? $upload->author_name ?? '',
|
||||
'username' => $upload->username ?? $upload->uname ?? '',
|
||||
'avatar_url' => $upload->avatar_url ?? null,
|
||||
'content_type_name' => $upload->content_type_name ?? '',
|
||||
'content_type_slug' => $upload->content_type_slug ?? '',
|
||||
'category_name' => $upload->category_name ?? '',
|
||||
'category_slug' => $upload->category_slug ?? '',
|
||||
'width' => $upload->width ?? null,
|
||||
'height' => $upload->height ?? null,
|
||||
'published_at' => !empty($upload->published_at)
|
||||
? (method_exists($upload->published_at, 'toIsoString') ? $upload->published_at->toIsoString() : (string) $upload->published_at)
|
||||
: null,
|
||||
]);
|
||||
$homeGalleryNextPageUrl = method_exists($latestUploads ?? null, 'nextPageUrl') ? $latestUploads->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
@@ -19,25 +42,15 @@
|
||||
@endpush
|
||||
|
||||
{{-- Latest uploads grid — use same Nova gallery layout as /browse --}}
|
||||
<section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads">
|
||||
<div class="{{ ($gridV2 ?? false) ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6' }}" data-gallery-grid>
|
||||
@forelse($latestUploads as $upload)
|
||||
<x-artwork-card :art="$upload" />
|
||||
@empty
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>No uploads yet</strong></div>
|
||||
<div class="panel-body text-neutral-400">No recent uploads to show.</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-10" data-gallery-pagination>
|
||||
{{-- no pagination for home grid; kept for parity with browse layout --}}
|
||||
</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
<section class="px-6 pb-10 pt-6 md:px-10">
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks='@json($homeGalleryArtworks)'
|
||||
data-gallery-type="home-uploads"
|
||||
@if ($homeGalleryNextPageUrl) data-next-page-url="{{ $homeGalleryNextPageUrl }}" @endif
|
||||
data-limit="{{ method_exists($latestUploads ?? null, 'perPage') ? $latestUploads->perPage() : $homeGalleryArtworks->count() }}"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
@push('styles')
|
||||
@@ -66,5 +79,6 @@
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
|
||||
@@ -1,43 +1,64 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$memberPhotoBreadcrumbs = collect([
|
||||
(object) ['name' => 'Members', 'url' => route('creators.top')],
|
||||
(object) ['name' => $page_title ?? 'Member Photos', 'url' => route('members.photos')],
|
||||
]);
|
||||
$memberPhotoItems = collect(method_exists($artworks ?? null, 'items') ? $artworks->items() : ($artworks ?? []));
|
||||
$memberPhotoGallery = $memberPhotoItems->map(fn ($art) => [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? $art->title ?? 'Artwork',
|
||||
'slug' => $art->slug ?? \Illuminate\Support\Str::slug($art->name ?? $art->title ?? 'artwork'),
|
||||
'url' => $art->url ?? ((isset($art->id) && $art->id) ? '/art/' . $art->id . '/' . ($art->slug ?? \Illuminate\Support\Str::slug($art->name ?? $art->title ?? 'artwork')) : '#'),
|
||||
'thumb' => $art->thumb ?? $art->thumb_url ?? null,
|
||||
'thumb_url' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? $art->author ?? '',
|
||||
'username' => $art->username ?? $art->uname ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'content_type_name' => $art->content_type_name ?? '',
|
||||
'content_type_slug' => $art->content_type_slug ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
'published_at' => !empty($art->published_at)
|
||||
? (method_exists($art->published_at, 'toIsoString') ? $art->published_at->toIsoString() : (string) $art->published_at)
|
||||
: null,
|
||||
]);
|
||||
$memberPhotosNextPageUrl = method_exists($artworks ?? null, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Members</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title ?? 'Member Photos' }}</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Artwork submitted by the Skinbase community.</p>
|
||||
</div>
|
||||
</div>
|
||||
<x-nova-page-header
|
||||
section="Members"
|
||||
:title="$page_title ?? 'Member Photos'"
|
||||
icon="fa-images"
|
||||
:breadcrumbs="$memberPhotoBreadcrumbs"
|
||||
description="Artwork submitted by the Skinbase community, presented in the shared Nova gallery feed."
|
||||
headerClass="pb-6"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-sky-500/10 text-sky-200 ring-1 ring-sky-400/25">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-300"></span>
|
||||
Community uploads
|
||||
</span>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@php $items = is_object($artworks) && method_exists($artworks, 'toArray') ? $artworks : collect($artworks ?? []); @endphp
|
||||
|
||||
@if (!empty($artworks) && (is_countable($artworks) ? count($artworks) > 0 : true))
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
@php
|
||||
$card = (object)[
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? $art->title ?? 'Artwork',
|
||||
'thumb' => $art->thumb ?? $art->thumb_url ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? $art->author ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'slug' => $art->slug ?? \Illuminate\Support\Str::slug($art->name ?? 'artwork'),
|
||||
];
|
||||
@endphp
|
||||
<x-artwork-card :art="$card" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (is_object($artworks) && method_exists($artworks, 'links'))
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($memberPhotoGallery->isNotEmpty())
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks='@json($memberPhotoGallery)'
|
||||
data-gallery-type="member-photos"
|
||||
@if ($memberPhotosNextPageUrl) data-next-page-url="{{ $memberPhotosNextPageUrl }}" @endif
|
||||
data-limit="{{ method_exists($artworks ?? null, 'perPage') ? $artworks->perPage() : $memberPhotoGallery->count() }}"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No artworks found.</p>
|
||||
@@ -46,3 +67,7 @@
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@php
|
||||
$hero_title = $mode === 'create' ? 'Write Story' : 'Edit Story';
|
||||
$hero_description = 'Medium-style editor with autosave, slash commands, artwork embeds, and publishing workflow.';
|
||||
$hero_description = 'A focused writing studio with autosave, embeds, live preview, and a cleaner publish workflow.';
|
||||
@endphp
|
||||
|
||||
@section('page-content')
|
||||
@@ -49,7 +49,7 @@
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="mx-auto max-w-3xl" id="story-editor-react-root"
|
||||
<div class="mx-auto max-w-7xl" id="story-editor-react-root"
|
||||
data-mode="{{ $mode }}"
|
||||
data-story='@json($storyPayload)'
|
||||
data-story-types='@json($storyTypes)'
|
||||
@@ -58,4 +58,4 @@
|
||||
Loading editor...
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@endsection
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<p class="mt-3 text-gray-300">{{ $story->excerpt }}</p>
|
||||
@endif
|
||||
|
||||
<div class="prose prose-invert mt-6 max-w-none prose-a:text-sky-300 prose-pre:bg-gray-900">
|
||||
<div class="story-prose prose prose-invert mt-6 max-w-none prose-a:text-sky-300 prose-pre:overflow-x-auto prose-pre:rounded-2xl prose-pre:border prose-pre:border-slate-700 prose-pre:bg-slate-950 prose-pre:px-8 prose-pre:py-6 prose-pre:text-slate-100 prose-pre:shadow-[0_24px_70px_rgba(2,6,23,0.45)] prose-pre:ring-1 prose-pre:ring-sky-500/10 prose-pre:font-mono prose-pre:text-[0.95rem] prose-pre:leading-8 prose-code:text-amber-200 prose-code:bg-white/[0.08] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:content-none prose-code:after:content-none prose-blockquote:border-l-4 prose-blockquote:border-sky-400/55 prose-blockquote:bg-sky-400/[0.06] prose-blockquote:px-5 prose-blockquote:py-3 prose-blockquote:rounded-r-xl prose-blockquote:text-white/82 prose-blockquote:italic prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-pre:prose-code:text-slate-100 prose-pre:prose-code:rounded-none [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-2 [&_li]:text-white/85">
|
||||
{!! $safeContent !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@php
|
||||
$hero_title = $story->title;
|
||||
$hero_description = $story->excerpt ?: \Illuminate\Support\Str::limit(strip_tags((string) $story->content), 160);
|
||||
$storySummary = $story->excerpt ?: \Illuminate\Support\Str::limit(trim(strip_tags($safeContent)), 160);
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
@php
|
||||
$storyUrl = $story->canonical_url ?: route('stories.show', ['slug' => $story->slug]);
|
||||
$creatorName = $story->creator?->display_name ?: $story->creator?->username ?: 'Unknown creator';
|
||||
$metaDescription = $story->meta_description ?: $hero_description;
|
||||
$metaDescription = $story->meta_description ?: $storySummary;
|
||||
$metaTitle = $story->meta_title ?: $story->title;
|
||||
$ogImage = $story->og_image ?: $story->cover_url;
|
||||
$creatorFollowProps = $story->creator ? [
|
||||
'username' => $story->creator->username,
|
||||
'following' => (bool) ($storySocialProps['state']['is_following_creator'] ?? false),
|
||||
'followers_count' => (int) ($storySocialProps['creator']['followers_count'] ?? 0),
|
||||
] : null;
|
||||
@endphp
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:title" content="{{ $metaTitle }}" />
|
||||
@@ -44,6 +48,10 @@
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@section('page-hero')
|
||||
<div class="hidden" aria-hidden="true"></div>
|
||||
@endsection
|
||||
|
||||
@section('page-content')
|
||||
<div class="mx-auto grid max-w-7xl gap-8 lg:grid-cols-12">
|
||||
<article class="lg:col-span-8">
|
||||
@@ -65,41 +73,13 @@
|
||||
<p class="mt-3 text-gray-300">{{ $story->excerpt }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 prose prose-invert max-w-none prose-a:text-sky-300 prose-pre:bg-gray-900">
|
||||
<div class="story-prose mt-6 prose prose-invert max-w-none prose-a:text-sky-300 prose-pre:overflow-x-auto prose-pre:rounded-2xl prose-pre:border prose-pre:border-slate-700 prose-pre:bg-slate-950 prose-pre:px-8 prose-pre:py-6 prose-pre:text-slate-100 prose-pre:shadow-[0_24px_70px_rgba(2,6,23,0.45)] prose-pre:ring-1 prose-pre:ring-sky-500/10 prose-pre:font-mono prose-pre:text-[0.95rem] prose-pre:leading-8 prose-code:text-amber-200 prose-code:bg-white/[0.08] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:content-none prose-code:after:content-none prose-blockquote:border-l-4 prose-blockquote:border-sky-400/55 prose-blockquote:bg-sky-400/[0.06] prose-blockquote:px-5 prose-blockquote:py-3 prose-blockquote:rounded-r-xl prose-blockquote:text-white/82 prose-blockquote:italic prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0 prose-pre:prose-code:text-slate-100 prose-pre:prose-code:rounded-none [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:space-y-2 [&_li]:text-white/85">
|
||||
{!! $safeContent !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mt-8 rounded-xl border border-gray-700 bg-gray-800/60 p-6">
|
||||
<h2 class="mb-5 text-xl font-semibold tracking-tight text-white">Discussion</h2>
|
||||
@if($comments->isEmpty())
|
||||
<p class="text-sm text-gray-400">No comments yet.</p>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@foreach($comments as $comment)
|
||||
<div class="rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<div class="mb-2 text-xs text-gray-400">
|
||||
{{ $comment->author_username ?? 'User' }}
|
||||
<span class="mx-1">•</span>
|
||||
{{ optional($comment->created_at)->diffForHumans() }}
|
||||
</div>
|
||||
<p class="text-sm leading-relaxed text-gray-200">{{ $comment->body }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@auth
|
||||
@if($story->creator)
|
||||
<form method="POST" action="{{ url('/@' . $story->creator->username . '/comment') }}" class="mt-5 space-y-3">
|
||||
@csrf
|
||||
<textarea name="body" rows="3" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white" placeholder="Add a comment for this creator"></textarea>
|
||||
<button class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-4 py-2 text-sm text-sky-200 transition hover:scale-[1.01]">Post comment</button>
|
||||
</form>
|
||||
@endif
|
||||
@endauth
|
||||
</section>
|
||||
<section class="mt-8" id="story-social-root" data-props='@json($storySocialProps)'></section>
|
||||
</article>
|
||||
|
||||
<aside class="space-y-8 lg:col-span-4">
|
||||
@@ -109,10 +89,7 @@
|
||||
<span>{{ $story->creator?->display_name ?: $story->creator?->username }}</span>
|
||||
</a>
|
||||
@if($story->creator)
|
||||
<form method="POST" action="{{ url('/@' . $story->creator->username . '/follow') }}" class="mt-4">
|
||||
@csrf
|
||||
<button class="w-full rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200 transition hover:scale-[1.01]">Follow Creator</button>
|
||||
</form>
|
||||
<div class="mt-4" id="story-creator-follow-root" data-props='@json($creatorFollowProps)'></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,55 +1,185 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$latestBreadcrumbs = collect([
|
||||
(object) ['name' => 'Uploads', 'url' => route('uploads.latest')],
|
||||
(object) ['name' => $page_title ?? 'Latest Artworks', 'url' => route('uploads.latest')],
|
||||
]);
|
||||
$cursorStateLabel = request()->filled('cursor') ? 'Browsing archive' : 'Latest slice';
|
||||
$cursorStateCopy = request()->filled('cursor')
|
||||
? 'You are viewing an older slice of the cursor-based feed.'
|
||||
: 'You are viewing the newest public uploads first.';
|
||||
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
|
||||
'id' => $art->id,
|
||||
'name' => $art->name ?? null,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_url' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->username ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'content_type_name' => $art->content_type_name ?? '',
|
||||
'content_type_slug' => $art->content_type_slug ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'slug' => $art->slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
'published_at' => optional($art->published_at)?->toIsoString() ?? null,
|
||||
'url' => isset($art->id) ? '/art/' . $art->id . '/' . ($art->slug ?: \Illuminate\Support\Str::slug($art->name ?? 'artwork')) : '#',
|
||||
])->values();
|
||||
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Skinbase</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">Latest Artworks</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Recently uploaded Skins, Photography and Wallpapers.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('uploads.daily') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<x-nova-page-header
|
||||
section="Uploads"
|
||||
:title="$page_title ?? 'Latest Artworks'"
|
||||
icon="fa-sparkles"
|
||||
:breadcrumbs="$latestBreadcrumbs"
|
||||
description="Fresh public uploads across skins, photography, wallpapers, and the rest of the Skinbase catalog."
|
||||
headerClass="pb-6"
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="{{ route('uploads.daily') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors"
|
||||
>
|
||||
<i class="fa-solid fa-calendar-day text-sky-300"></i>
|
||||
Daily Uploads
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('discover.fresh') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-sky-400/20 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15 transition-colors"
|
||||
>
|
||||
<i class="fa-solid fa-compass text-sky-300"></i>
|
||||
Discover Fresh
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<section class="px-6 pt-8 md:px-10">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="overflow-hidden rounded-[1.5rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_34%),linear-gradient(135deg,rgba(11,17,27,0.96),rgba(10,16,24,0.88))] p-5 shadow-[0_20px_70px_rgba(3,7,18,0.24)] md:p-6">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-white/58">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200">
|
||||
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
|
||||
Live feed
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-medium text-white/60">
|
||||
Ordered by newest first
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">This page</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($artworks->count()) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">Artworks loaded in the current slice.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Position</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ $cursorStateLabel }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">{{ $cursorStateCopy }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Per page</p>
|
||||
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($artworks->perPage()) }}</p>
|
||||
<p class="mt-2 text-sm text-white/45">Balanced for a fast modern gallery load.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div class="rounded-[1.5rem] border border-white/[0.08] bg-white/[0.03] p-5 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/38">Gallery mode</p>
|
||||
<h2 class="mt-3 text-lg font-semibold text-white">Nova Gallery</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-white/52">A denser card wall with the same Skinbase artwork cards used across discover and browse surfaces.</p>
|
||||
</div>
|
||||
<div class="rounded-[1.5rem] border border-white/[0.08] bg-white/[0.03] p-5 backdrop-blur-sm">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/38">Content mix</p>
|
||||
<h2 class="mt-3 text-lg font-semibold text-white">All public uploads</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-white/52">Skins, photography, wallpapers, and everything else released publicly, sorted strictly by recency.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if ($artworks && $artworks->isNotEmpty())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
@php
|
||||
$card = (object)[
|
||||
'id' => $art->id,
|
||||
'name' => $art->name,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
];
|
||||
@endphp
|
||||
<x-artwork-card :art="$card" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No artworks found.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks='@json($galleryArtworks)'
|
||||
data-gallery-type="latest-uploads"
|
||||
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||
data-limit="{{ $artworks->perPage() }}"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
grid-auto-rows: 8px;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 2600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] ul {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] li a,
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] li span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0 0.75rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: #e6eef8;
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
Reference in New Issue
Block a user