update
This commit is contained in:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user