Commit workspace changes
This commit is contained in:
@@ -102,6 +102,50 @@ function visibilityLabel(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContributorCredits(contributorIds = [], contributorCredits = {}) {
|
||||
const normalized = {}
|
||||
const ids = Array.isArray(contributorIds)
|
||||
? contributorIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
|
||||
: []
|
||||
|
||||
ids.forEach((id) => {
|
||||
const current = contributorCredits?.[id] || contributorCredits?.[String(id)] || {}
|
||||
normalized[id] = {
|
||||
creditRole: typeof current.creditRole === 'string' ? current.creditRole : '',
|
||||
isPrimary: Boolean(current.isPrimary),
|
||||
}
|
||||
})
|
||||
|
||||
const leadIds = Object.entries(normalized)
|
||||
.filter(([, value]) => value.isPrimary)
|
||||
.map(([id]) => Number(id))
|
||||
|
||||
if (leadIds.length > 1) {
|
||||
leadIds.slice(1).forEach((id) => {
|
||||
normalized[id] = {
|
||||
...normalized[id],
|
||||
isPrimary: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function mapContributorCredits(contributorCredits = []) {
|
||||
return (Array.isArray(contributorCredits) ? contributorCredits : []).reduce((accumulator, contributor) => {
|
||||
const userId = Number(contributor?.user_id)
|
||||
if (!Number.isFinite(userId) || userId <= 0) return accumulator
|
||||
|
||||
accumulator[userId] = {
|
||||
creditRole: typeof contributor?.credit_role === 'string' ? contributor.credit_role : '',
|
||||
isPrimary: Boolean(contributor?.is_primary),
|
||||
}
|
||||
|
||||
return accumulator
|
||||
}, {})
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Glass-morphism section card (Nova theme) */
|
||||
@@ -160,6 +204,10 @@ function RightRailCard({ title, children, className = '' }) {
|
||||
export default function StudioArtworkEdit() {
|
||||
const { props } = usePage()
|
||||
const { artwork, contentTypes: rawContentTypes } = props
|
||||
const groupOptions = Array.isArray(props.groupOptions) ? props.groupOptions : []
|
||||
const contributorOptionsByGroup = props.contributorOptionsByGroup && typeof props.contributorOptionsByGroup === 'object'
|
||||
? props.contributorOptionsByGroup
|
||||
: {}
|
||||
|
||||
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
|
||||
|
||||
@@ -173,6 +221,10 @@ export default function StudioArtworkEdit() {
|
||||
const [visibility, setVisibility] = useState(artwork?.visibility || (artwork?.is_public ? 'public' : 'private'))
|
||||
const [publishMode, setPublishMode] = useState(artwork?.publish_mode || (artwork?.artwork_status === 'scheduled' ? 'schedule' : 'now'))
|
||||
const [scheduledAt, setScheduledAt] = useState(artwork?.publish_at || null)
|
||||
const [groupSlug, setGroupSlug] = useState(artwork?.group_slug || '')
|
||||
const [primaryAuthorUserId, setPrimaryAuthorUserId] = useState(artwork?.primary_author_user_id || null)
|
||||
const [contributorUserIds, setContributorUserIds] = useState(() => (Array.isArray(artwork?.contributor_user_ids) ? artwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : []))
|
||||
const [contributorCredits, setContributorCredits] = useState(() => normalizeContributorCredits(artwork?.contributor_user_ids || [], mapContributorCredits(artwork?.contributor_credits || [])))
|
||||
const [titleSource, setTitleSource] = useState(artwork?.title_source || 'manual')
|
||||
const [descriptionSource, setDescriptionSource] = useState(artwork?.description_source || 'manual')
|
||||
const [tagsSource, setTagsSource] = useState(artwork?.tags_source || 'manual')
|
||||
@@ -218,6 +270,11 @@ export default function StudioArtworkEdit() {
|
||||
const rootCategories = selectedCT?.rootCategories || []
|
||||
const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null
|
||||
const subCategories = selectedRoot?.children || []
|
||||
const selectedGroupOption = useMemo(() => groupOptions.find((group) => String(group.slug || '') === String(groupSlug || '')) || null, [groupOptions, groupSlug])
|
||||
const currentContributorOptions = useMemo(() => {
|
||||
const selectedSlug = String(groupSlug || '')
|
||||
return Array.isArray(contributorOptionsByGroup?.[selectedSlug]) ? contributorOptionsByGroup[selectedSlug] : []
|
||||
}, [contributorOptionsByGroup, groupSlug])
|
||||
const aiStatus = aiData?.status || artwork?.ai_status || 'not_analyzed'
|
||||
const aiSuggestedTags = useMemo(() => (aiData?.tag_suggestions || []).map((item) => item.tag).filter(Boolean), [aiData])
|
||||
const selectedLeafCategoryId = subCategoryId || categoryId || null
|
||||
@@ -491,6 +548,45 @@ export default function StudioArtworkEdit() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [aiStatus, loadAiData])
|
||||
|
||||
useEffect(() => {
|
||||
const selectedSlug = String(groupSlug || '')
|
||||
if (!selectedSlug) {
|
||||
if (primaryAuthorUserId || contributorUserIds.length > 0 || Object.keys(contributorCredits || {}).length > 0) {
|
||||
setPrimaryAuthorUserId(null)
|
||||
setContributorUserIds([])
|
||||
setContributorCredits({})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const validGroup = groupOptions.some((group) => String(group.slug || '') === selectedSlug)
|
||||
if (!validGroup) {
|
||||
setGroupSlug('')
|
||||
setPrimaryAuthorUserId(null)
|
||||
setContributorUserIds([])
|
||||
setContributorCredits({})
|
||||
return
|
||||
}
|
||||
|
||||
const validContributorIds = currentContributorOptions.map((user) => Number(user.id)).filter((id) => Number.isFinite(id) && id > 0)
|
||||
const nextPrimaryAuthorId = validContributorIds.includes(Number(primaryAuthorUserId))
|
||||
? Number(primaryAuthorUserId)
|
||||
: (validContributorIds[0] || null)
|
||||
|
||||
const nextContributorIds = contributorUserIds
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => validContributorIds.includes(id) && id !== nextPrimaryAuthorId)
|
||||
|
||||
const nextContributorCredits = normalizeContributorCredits(nextContributorIds, contributorCredits)
|
||||
const primaryChanged = (primaryAuthorUserId ? Number(primaryAuthorUserId) : null) !== nextPrimaryAuthorId
|
||||
const contributorsChanged = nextContributorIds.length !== contributorUserIds.length || nextContributorIds.some((id, index) => id !== contributorUserIds[index])
|
||||
const contributorCreditsChanged = JSON.stringify(nextContributorCredits) !== JSON.stringify(normalizeContributorCredits(contributorUserIds, contributorCredits))
|
||||
|
||||
if (primaryChanged) setPrimaryAuthorUserId(nextPrimaryAuthorId)
|
||||
if (contributorsChanged) setContributorUserIds(nextContributorIds)
|
||||
if (contributorCreditsChanged) setContributorCredits(nextContributorCredits)
|
||||
}, [groupSlug, groupOptions, currentContributorOptions, primaryAuthorUserId, contributorUserIds, contributorCredits])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
@@ -503,6 +599,16 @@ export default function StudioArtworkEdit() {
|
||||
mode: publishMode,
|
||||
publish_at: publishMode === 'schedule' ? scheduledAt : null,
|
||||
timezone: userTimezone,
|
||||
group: groupSlug || null,
|
||||
primary_author_user_id: groupSlug ? primaryAuthorUserId : null,
|
||||
contributor_user_ids: groupSlug ? contributorUserIds : [],
|
||||
contributor_credits: groupSlug
|
||||
? contributorUserIds.map((id) => ({
|
||||
user_id: id,
|
||||
credit_role: contributorCredits?.[id]?.creditRole?.trim() ? contributorCredits[id].creditRole.trim() : null,
|
||||
is_primary: Boolean(contributorCredits?.[id]?.isPrimary),
|
||||
}))
|
||||
: [],
|
||||
content_type_id: contentTypeId,
|
||||
category_id: selectedLeafCategoryId,
|
||||
tags: tagSlugs,
|
||||
@@ -524,6 +630,10 @@ export default function StudioArtworkEdit() {
|
||||
setVisibility(updatedArtwork.visibility || visibility)
|
||||
setPublishMode(updatedArtwork.publish_mode || 'now')
|
||||
setScheduledAt(updatedArtwork.publish_at || null)
|
||||
setGroupSlug(updatedArtwork.group_slug || '')
|
||||
setPrimaryAuthorUserId(updatedArtwork.primary_author_user_id || null)
|
||||
setContributorUserIds(Array.isArray(updatedArtwork.contributor_user_ids) ? updatedArtwork.contributor_user_ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) : [])
|
||||
setContributorCredits(normalizeContributorCredits(updatedArtwork.contributor_user_ids || [], mapContributorCredits(updatedArtwork.contributor_credits || [])))
|
||||
}
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
@@ -536,7 +646,7 @@ export default function StudioArtworkEdit() {
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, artwork?.id])
|
||||
}, [title, description, visibility, publishMode, scheduledAt, userTimezone, groupSlug, primaryAuthorUserId, contributorUserIds, contributorCredits, contentTypeId, selectedLeafCategoryId, tagSlugs, titleSource, descriptionSource, tagsSource, categorySource, artwork?.id])
|
||||
|
||||
const handleFileReplace = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -1050,6 +1160,159 @@ export default function StudioArtworkEdit() {
|
||||
autofocus={false}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Section className="space-y-5 border-white/8 bg-white/[0.02]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<SectionTitle icon="fa-solid fa-users">Attribution</SectionTitle>
|
||||
<p className="-mt-2 text-sm text-slate-400">Switch between personal and group context, then maintain primary author and contributor credits without leaving the edit screen.</p>
|
||||
</div>
|
||||
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${groupSlug ? 'border-sky-300/20 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300'}`}>
|
||||
{selectedGroupOption ? `Group: ${selectedGroupOption.name}` : 'Personal publish'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Publishing identity</span>
|
||||
<select
|
||||
value={groupSlug}
|
||||
onChange={(event) => setGroupSlug(event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
>
|
||||
<option value="">Personal profile</option>
|
||||
{groupOptions.map((group) => (
|
||||
<option key={group.slug} value={group.slug}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.group?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.group[0]}</p> : null}
|
||||
</label>
|
||||
|
||||
{groupSlug ? (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Primary author</span>
|
||||
<select
|
||||
value={primaryAuthorUserId || ''}
|
||||
onChange={(event) => setPrimaryAuthorUserId(event.target.value ? Number(event.target.value) : null)}
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
>
|
||||
{currentContributorOptions.map((user) => (
|
||||
<option key={user.id} value={user.id}>{user.name || user.username}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{errors.primary_author_user_id?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.primary_author_user_id[0]}</p> : <p className="mt-2 text-xs text-slate-400">Primary author remains the lead creator shown on the public artwork page.</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-white/90">Contributors</span>
|
||||
<span className="text-xs text-slate-500">Optional</span>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{currentContributorOptions.filter((user) => Number(user.id) !== Number(primaryAuthorUserId)).map((user) => {
|
||||
const active = contributorUserIds.some((id) => Number(id) === Number(user.id))
|
||||
const creditMeta = contributorCredits?.[user.id] || contributorCredits?.[String(user.id)] || { creditRole: '', isPrimary: false }
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className={[
|
||||
'rounded-2xl border px-3 py-3 transition',
|
||||
active
|
||||
? 'border-sky-300/30 bg-sky-300/10 text-white'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{user.avatar_url ? <img src={user.avatar_url} alt={user.name || user.username} className="h-10 w-10 rounded-2xl object-cover" /> : <div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{user.name || user.username}</div>
|
||||
<div className="truncate text-xs text-slate-400">@{user.username}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setContributorUserIds((current) => {
|
||||
const nextIds = new Set(current.map((id) => Number(id)).filter((id) => id !== Number(primaryAuthorUserId)))
|
||||
if (nextIds.has(Number(user.id))) {
|
||||
nextIds.delete(Number(user.id))
|
||||
} else {
|
||||
nextIds.add(Number(user.id))
|
||||
}
|
||||
|
||||
const normalizedIds = Array.from(nextIds)
|
||||
setContributorCredits((currentCredits) => normalizeContributorCredits(normalizedIds, currentCredits))
|
||||
return normalizedIds
|
||||
})
|
||||
}}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition',
|
||||
active
|
||||
? 'border-sky-300/40 bg-sky-300/20 text-sky-50'
|
||||
: 'border-white/10 bg-white/[0.03] text-white/70 hover:border-white/20 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={['inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px]', active ? 'border-sky-300/40 bg-sky-300/20 text-sky-50' : 'border-white/10 bg-white/[0.03] text-white/35'].join(' ')}>{active ? '✓' : ''}</span>
|
||||
{active ? 'Added' : 'Add credit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium uppercase tracking-[0.16em] text-slate-300">Credit role</span>
|
||||
<input
|
||||
type="text"
|
||||
value={creditMeta.creditRole || ''}
|
||||
onChange={(event) => setContributorCredits((current) => ({
|
||||
...normalizeContributorCredits(contributorUserIds, current),
|
||||
[Number(user.id)]: {
|
||||
...(normalizeContributorCredits(contributorUserIds, current)[Number(user.id)] || { isPrimary: false }),
|
||||
creditRole: event.target.value,
|
||||
},
|
||||
}))}
|
||||
placeholder="Colorist, concept support, layout..."
|
||||
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContributorCredits((current) => {
|
||||
const nextCredits = normalizeContributorCredits(contributorUserIds, current)
|
||||
contributorUserIds.forEach((id) => {
|
||||
nextCredits[id] = {
|
||||
...(nextCredits[id] || { creditRole: '' }),
|
||||
isPrimary: id === Number(user.id),
|
||||
}
|
||||
})
|
||||
return nextCredits
|
||||
})}
|
||||
className={[
|
||||
'inline-flex items-center justify-center rounded-xl border px-3 py-3 text-sm font-medium transition',
|
||||
creditMeta.isPrimary
|
||||
? 'border-emerald-300/35 bg-emerald-400/12 text-emerald-100'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{creditMeta.isPrimary ? 'Lead support' : 'Set lead support'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errors.contributor_credits?.[0] ? <p className="mt-2 text-xs text-red-400">{errors.contributor_credits[0]}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
|
||||
Personal publishing uses your own creator profile as the primary author automatically.
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
32
resources/js/Pages/Studio/StudioGroupActivity.jsx
Normal file
32
resources/js/Pages/Studio/StudioGroupActivity.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupActivity() {
|
||||
const { props } = usePage()
|
||||
const items = Array.isArray(props.activity) ? props.activity : []
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="space-y-4">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<div key={item.id} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-white">{item.headline}</h2>
|
||||
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.visibility}</span>
|
||||
</div>
|
||||
{item.summary ? <p className="mt-2 text-sm leading-6 text-slate-400">{item.summary}</p> : null}
|
||||
<div className="mt-3 text-xs text-slate-500">{item.actor?.name || item.actor?.username || 'System'} • {item.occurred_at ? new Date(item.occurred_at).toLocaleString() : 'Recently'}</div>
|
||||
{item.subject?.url ? <a href={item.subject.url} className="mt-3 inline-flex text-sm font-semibold text-sky-200">Open subject</a> : null}
|
||||
</div>
|
||||
{props.pinPattern ? <button type="button" onClick={() => router.post(props.pinPattern.replace('__ITEM__', String(item.id)), { is_pinned: !item.is_pinned })} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white">{item.is_pinned ? 'Unpin' : 'Pin'}</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No activity yet.</div>}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
19
resources/js/Pages/Studio/StudioGroupArtworks.jsx
Normal file
19
resources/js/Pages/Studio/StudioGroupArtworks.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
|
||||
|
||||
export default function StudioGroupArtworks() {
|
||||
const { props } = usePage()
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="mb-6 rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em]">Group publish flow</p>
|
||||
<h2 className="mt-2 text-xl font-semibold">Upload into {props.studioGroup?.name}</h2>
|
||||
<a href={props.uploadUrl} className="mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50">New group artwork</a>
|
||||
</div>
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={[{ key: 'artworks', label: 'Artwork', icon: 'fa-solid fa-cloud-arrow-up', url: props.uploadUrl }]} hideModuleFilter />
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
106
resources/js/Pages/Studio/StudioGroupAssets.jsx
Normal file
106
resources/js/Pages/Studio/StudioGroupAssets.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupAssets() {
|
||||
const { props } = usePage()
|
||||
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
|
||||
const filters = useForm({
|
||||
q: props.listing?.filters?.q || '',
|
||||
category: props.listing?.filters?.category || 'all',
|
||||
bucket: props.listing?.filters?.bucket || 'all',
|
||||
})
|
||||
const form = useForm({
|
||||
title: '',
|
||||
description: '',
|
||||
category: props.categoryOptions?.[0]?.value || 'misc',
|
||||
visibility: props.visibilityOptions?.[0]?.value || 'members_only',
|
||||
status: props.statusOptions?.[0]?.value || 'active',
|
||||
linked_project_id: '',
|
||||
is_featured: false,
|
||||
file: null,
|
||||
})
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
if (!props.storeUrl) return
|
||||
form.post(props.storeUrl, { forceFormData: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
const applyFilters = (event) => {
|
||||
event.preventDefault()
|
||||
router.get(props.studioGroup?.urls?.studio_assets || window.location.pathname, {
|
||||
q: filters.data.q || undefined,
|
||||
category: filters.data.category !== 'all' ? filters.data.category : undefined,
|
||||
bucket: filters.data.bucket !== 'all' ? filters.data.bucket : undefined,
|
||||
}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
{props.storeUrl ? (
|
||||
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-4 lg:grid-cols-6">
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Asset title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none lg:col-span-2" />
|
||||
<select value={form.data.category} onChange={(event) => form.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<input type="file" onChange={(event) => form.setData('file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="What is this asset for?" rows={3} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured asset</label>
|
||||
</div>
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Upload asset</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Browse library</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Search and filter shared assets by visibility and category.</p>
|
||||
</div>
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Apply filters</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<input value={filters.data.q} onChange={(event) => filters.setData('q', event.target.value)} placeholder="Search title, description, or filename" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={filters.data.category} onChange={(event) => filters.setData('category', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="all">All categories</option>
|
||||
{(props.categoryOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<select value={filters.data.bucket} onChange={(event) => filters.setData('bucket', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="all">All visibility levels</option>
|
||||
{(props.listing?.bucket_options || []).filter((option) => option.value !== 'all').map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{items.length > 0 ? items.map((asset) => (
|
||||
<div key={asset.id} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{asset.title}</h2>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{asset.category} • {asset.visibility} • {asset.status}</p>
|
||||
</div>
|
||||
<a href={asset.download_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white">Download</a>
|
||||
</div>
|
||||
{asset.description ? <p className="mt-3 text-sm leading-6 text-slate-400">{asset.description}</p> : null}
|
||||
{props.updatePattern ? (
|
||||
<button type="button" onClick={() => router.patch(props.updatePattern.replace('__ASSET__', String(asset.id)), { title: asset.title, description: asset.description || '', category: asset.category, visibility: asset.visibility, status: asset.status === 'active' ? 'archived' : 'active', linked_project_id: asset.linked_project?.id || '', is_featured: asset.is_featured })} className="mt-4 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{asset.status === 'active' ? 'Archive' : 'Reactivate'}</button>
|
||||
) : null}
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No assets yet.</div>}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
95
resources/js/Pages/Studio/StudioGroupChallengeEditor.jsx
Normal file
95
resources/js/Pages/Studio/StudioGroupChallengeEditor.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
import { useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupChallengeEditor() {
|
||||
const { props } = usePage()
|
||||
const challenge = props.challenge || null
|
||||
const form = useForm({
|
||||
title: challenge?.title || '',
|
||||
summary: challenge?.summary || '',
|
||||
description: challenge?.description || '',
|
||||
visibility: challenge?.visibility || props.visibilityOptions?.[0]?.value || 'public',
|
||||
participation_scope: challenge?.participation_scope || props.participationScopeOptions?.[0]?.value || 'group_only',
|
||||
status: challenge?.status || props.statusOptions?.[0]?.value || 'draft',
|
||||
start_at: challenge?.start_at ? challenge.start_at.slice(0, 16) : '',
|
||||
end_at: challenge?.end_at ? challenge.end_at.slice(0, 16) : '',
|
||||
rules_text: challenge?.rules_text || '',
|
||||
submission_instructions: challenge?.submission_instructions || '',
|
||||
judging_mode: challenge?.judging_mode || '',
|
||||
linked_collection_id: challenge?.linked_collection?.id || '',
|
||||
linked_project_id: challenge?.linked_project?.id || '',
|
||||
featured_artwork_id: challenge?.featured_artwork?.id || '',
|
||||
cover_file: null,
|
||||
})
|
||||
const attachForm = useForm({ artwork_id: '' })
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const options = { forceFormData: true, preserveScroll: true }
|
||||
if (props.updateUrl) {
|
||||
form.post(props.updateUrl, { ...options, _method: 'patch' })
|
||||
return
|
||||
}
|
||||
form.post(props.storeUrl, options)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-4">
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Challenge title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Challenge description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.participation_scope} onChange={(event) => form.setData('participation_scope', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.participationScopeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<textarea value={form.data.rules_text} onChange={(event) => form.setData('rules_text', event.target.value)} placeholder="Rules" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.submission_instructions} onChange={(event) => form.setData('submission_instructions', event.target.value)} placeholder="Submission instructions" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={form.data.judging_mode} onChange={(event) => form.setData('judging_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No judging mode</option>
|
||||
{(props.judgingModeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No featured artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save challenge</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-6">
|
||||
{props.publishUrl ? <form onSubmit={(event) => { event.preventDefault(); form.post(props.publishUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6"><button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Publish challenge</button></form> : null}
|
||||
{props.attachArtworkUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); attachForm.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
|
||||
<select value={attachForm.data.artwork_id} onChange={(event) => attachForm.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
29
resources/js/Pages/Studio/StudioGroupChallenges.jsx
Normal file
29
resources/js/Pages/Studio/StudioGroupChallenges.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupChallenges() {
|
||||
const { props } = usePage()
|
||||
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-slate-400">Challenges keep the group active between releases and give members a focused creative prompt.</div>
|
||||
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create challenge</a> : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{items.length > 0 ? items.map((challenge) => (
|
||||
<a key={challenge.id} href={challenge.urls?.edit || challenge.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold text-white">{challenge.title}</h2>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{challenge.status}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-400">{challenge.summary || 'Challenge page'}</p>
|
||||
<div className="mt-4 text-xs text-slate-500">{challenge.entry_count || 0} linked entries</div>
|
||||
</a>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No challenges yet.</div>}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
19
resources/js/Pages/Studio/StudioGroupCollections.jsx
Normal file
19
resources/js/Pages/Studio/StudioGroupCollections.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import StudioContentBrowser from '../../components/Studio/StudioContentBrowser'
|
||||
|
||||
export default function StudioGroupCollections() {
|
||||
const { props } = usePage()
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="mb-6 rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em]">Shared curation</p>
|
||||
<h2 className="mt-2 text-xl font-semibold">Create collections for {props.studioGroup?.name}</h2>
|
||||
<a href={props.createUrl} className="mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50">New group collection</a>
|
||||
</div>
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={[{ key: 'collections', label: 'Collection', icon: 'fa-solid fa-layer-group', url: props.createUrl }]} hideModuleFilter />
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
190
resources/js/Pages/Studio/StudioGroupCreate.jsx
Normal file
190
resources/js/Pages/Studio/StudioGroupCreate.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
|
||||
|
||||
export default function StudioGroupCreate() {
|
||||
const { props } = usePage()
|
||||
const avatarInputRef = useRef(null)
|
||||
const bannerInputRef = useRef(null)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
headline: '',
|
||||
bio: '',
|
||||
type: '',
|
||||
founded_at: '',
|
||||
avatar_path: '',
|
||||
banner_path: '',
|
||||
visibility: 'public',
|
||||
membership_policy: 'invite_only',
|
||||
website_url: '',
|
||||
links_json: [{ label: '', url: '' }],
|
||||
avatar_file: null,
|
||||
banner_file: null,
|
||||
})
|
||||
const [avatarPreview, setAvatarPreview] = useState('')
|
||||
const [bannerPreview, setBannerPreview] = useState('')
|
||||
|
||||
const updateLink = (index, key, value) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
links_json: current.links_json.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item),
|
||||
}))
|
||||
}
|
||||
|
||||
const addLink = () => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
links_json: [...current.links_json, { label: '', url: '' }],
|
||||
}))
|
||||
}
|
||||
|
||||
const removeLink = (index) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
links_json: current.links_json.filter((_, itemIndex) => itemIndex !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
router.post(props.endpoints?.store, {
|
||||
...form,
|
||||
links_json: form.links_json.filter((item) => item.label.trim() !== '' || item.url.trim() !== ''),
|
||||
}, {
|
||||
forceFormData: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileSelected = (field, setPreview) => (event) => {
|
||||
const file = event.target.files?.[0] || null
|
||||
|
||||
setForm((current) => ({ ...current, [field]: file }))
|
||||
setPreview(file ? URL.createObjectURL(file) : '')
|
||||
}
|
||||
|
||||
const clearSelectedFile = (field, setPreview, inputRef) => {
|
||||
setForm((current) => ({ ...current, [field]: null }))
|
||||
setPreview('')
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="mx-auto mb-6 max-w-5xl">
|
||||
<GroupStudioPromoCard
|
||||
title="Set up the public identity before the first release"
|
||||
description="A strong group page makes collaborative publishing legible: who leads the team, what kind of work you make, and why contributors should join or follow."
|
||||
bullets={[
|
||||
{ title: 'Headline first', body: 'Use the headline to explain what the collective publishes and what makes the group distinct.' },
|
||||
{ title: 'Recruit with clarity', body: 'After creation, configure recruitment so open roles surface across search and browse experiences.' },
|
||||
{ title: 'Own the presentation', body: 'Avatar, cover art, and links shape how the group appears on artworks, profile summaries, and leaderboards.' },
|
||||
]}
|
||||
primaryLabel="Back to groups"
|
||||
primaryHref="/studio/groups"
|
||||
secondaryLabel="Browse public groups"
|
||||
secondaryHref="/groups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-5">
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Name</span>
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value, slug: current.slug || event.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Slug</span>
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Short description</span>
|
||||
<input value={form.headline} onChange={(event) => setForm((current) => ({ ...current, headline: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>About</span>
|
||||
<textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Type / category</span>
|
||||
<input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Founded date</span>
|
||||
<input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Website</span>
|
||||
<input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<span className="text-sm font-semibold text-white">Avatar / logo</span>
|
||||
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
|
||||
{avatarPreview || form.avatar_path ? <img src={avatarPreview || form.avatar_path} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
|
||||
</div>
|
||||
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => avatarInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload avatar</button>
|
||||
{form.avatar_file ? <button type="button" onClick={() => clearSelectedFile('avatar_file', setAvatarPreview, avatarInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use URL instead</button> : null}
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Or paste an image URL</span>
|
||||
<input value={form.avatar_path} onChange={(event) => setForm((current) => ({ ...current, avatar_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<span className="text-sm font-semibold text-white">Cover image</span>
|
||||
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
|
||||
{bannerPreview || form.banner_path ? <img src={bannerPreview || form.banner_path} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
|
||||
</div>
|
||||
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => bannerInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload cover</button>
|
||||
{form.banner_file ? <button type="button" onClick={() => clearSelectedFile('banner_file', setBannerPreview, bannerInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use URL instead</button> : null}
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Or paste an image URL</span>
|
||||
<input value={form.banner_path} onChange={(event) => setForm((current) => ({ ...current, banner_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Visibility</span>
|
||||
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Membership policy</span>
|
||||
<select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-200">Links</span>
|
||||
<button type="button" onClick={addLink} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Add link</button>
|
||||
</div>
|
||||
{form.links_json.map((item, index) => (
|
||||
<div key={`link-${index}`} className="grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]">
|
||||
<input value={item.label} onChange={(event) => updateLink(index, 'label', event.target.value)} placeholder="Label" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={item.url} onChange={(event) => updateLink(index, 'url', event.target.value)} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={() => removeLink(index)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<a href="/studio/groups" className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white">Cancel</a>
|
||||
<button type="button" onClick={submit} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Create group</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
400
resources/js/Pages/Studio/StudioGroupDashboard.jsx
Normal file
400
resources/js/Pages/Studio/StudioGroupDashboard.jsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function StatCard({ label, value, icon }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center gap-3 text-slate-300"><i className={icon} /><span>{label}</span></div>
|
||||
<div className="mt-3 text-3xl font-semibold text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentCard({ item, fallbackLabel }) {
|
||||
return (
|
||||
<a href={item.manage_url || item.urls?.edit || item.edit_url || item.preview_url || item.view_url || item.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 transition hover:border-white/20">
|
||||
{item.image_url ? <img src={item.image_url} alt={item.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500"><i className="fa-solid fa-image text-2xl" /></div>}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-white">{item.title}</h3>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status || item.event_type || fallbackLabel}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.description || item.summary || fallbackLabel}</p>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyCard({ title, description }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">
|
||||
<p className="font-semibold text-white">{title}</p>
|
||||
<p className="mt-2 leading-6">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityCard({ item }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-semibold text-white">{item.headline}</div>
|
||||
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
|
||||
</div>
|
||||
{item.summary ? <p className="mt-2 text-sm text-slate-400">{item.summary}</p> : null}
|
||||
<div className="mt-2 text-xs text-slate-500">{item.actor?.name || item.actor?.username || 'System'} • {item.occurred_at ? new Date(item.occurred_at).toLocaleString() : 'Recently'}</div>
|
||||
{item.subject?.url ? <a href={item.subject.url} className="mt-3 inline-flex text-sm font-semibold text-sky-200">Open subject</a> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioGroupDashboard() {
|
||||
const { props } = usePage()
|
||||
const group = props.studioGroup
|
||||
const members = Array.isArray(props.members) ? props.members : []
|
||||
const dashboard = props.dashboard || {}
|
||||
const draftsPendingAction = Array.isArray(props.draftsPendingAction) ? props.draftsPendingAction : []
|
||||
const recentArtworks = Array.isArray(props.recentArtworks) ? props.recentArtworks : []
|
||||
const recentCollections = Array.isArray(props.recentCollections) ? props.recentCollections : []
|
||||
const recentPosts = Array.isArray(props.recentPosts) ? props.recentPosts : []
|
||||
const recentProjects = Array.isArray(props.recentProjects) ? props.recentProjects : []
|
||||
const recentReleases = Array.isArray(props.recentReleases) ? props.recentReleases : []
|
||||
const recentChallenges = Array.isArray(props.recentChallenges) ? props.recentChallenges : []
|
||||
const recentEvents = Array.isArray(props.recentEvents) ? props.recentEvents : []
|
||||
const recentActivity = Array.isArray(props.recentActivity) ? props.recentActivity : []
|
||||
const trustSignals = Array.isArray(props.trustSignals) ? props.trustSignals : []
|
||||
const reputationSummary = props.reputationSummary || {}
|
||||
const pendingJoinRequests = Array.isArray(props.pendingJoinRequests) ? props.pendingJoinRequests : []
|
||||
const reviewQueuePreview = Array.isArray(props.reviewQueuePreview) ? props.reviewQueuePreview : []
|
||||
const recruitment = props.recruitment || null
|
||||
const recentHistory = Array.isArray(props.recentHistory) ? props.recentHistory : []
|
||||
|
||||
const roleSummary = members.reduce((summary, member) => {
|
||||
const role = String(member.role || 'member')
|
||||
summary[role] = (summary[role] || 0) + 1
|
||||
return summary
|
||||
}, {})
|
||||
|
||||
const quickActions = [
|
||||
{ label: 'Upload artwork', href: group?.urls?.upload, icon: 'fa-solid fa-cloud-arrow-up', tone: 'sky', detail: 'Start a new group-published artwork.' },
|
||||
{ label: 'Invite member', href: group?.urls?.studio_invitations, icon: 'fa-solid fa-user-plus', tone: 'emerald', detail: `Manage invites${Number(dashboard.pending_invites_count || 0) > 0 ? ` (${Number(dashboard.pending_invites_count)})` : ''}.` },
|
||||
{ label: 'Review queue', href: group?.urls?.studio_review, icon: 'fa-solid fa-list-check', tone: 'amber', detail: `${Number(dashboard.pending_reviews_count || 0)} submissions waiting.` },
|
||||
{ label: 'Posts', href: group?.urls?.studio_posts, icon: 'fa-solid fa-bullhorn', tone: 'violet', detail: `${Number(dashboard.published_posts_count || 0)} published posts.` },
|
||||
{ label: 'Projects', href: group?.urls?.studio_projects, icon: 'fa-solid fa-diagram-project', tone: 'sky', detail: `${Number(dashboard.projects_count || 0)} total projects.` },
|
||||
{ label: 'Releases', href: group?.urls?.studio_releases, icon: 'fa-solid fa-rocket', tone: 'amber', detail: `${Number(dashboard.published_releases_count || 0)} published releases.` },
|
||||
{ label: 'Challenges', href: group?.urls?.studio_challenges, icon: 'fa-solid fa-trophy', tone: 'amber', detail: `${Number(dashboard.active_challenges_count || 0)} active or published.` },
|
||||
{ label: 'Events', href: group?.urls?.studio_events, icon: 'fa-solid fa-calendar-day', tone: 'emerald', detail: `${Number(dashboard.events_count || 0)} scheduled or archived.` },
|
||||
{ label: 'Assets', href: group?.urls?.studio_assets, icon: 'fa-solid fa-box-archive', tone: 'violet', detail: `${Number(dashboard.assets_count || 0)} shared files.` },
|
||||
{ label: 'Reputation', href: group?.urls?.studio_reputation, icon: 'fa-solid fa-shield-heart', tone: 'sky', detail: `${Number(reputationSummary?.counts?.contributors || 0)} contributors tracked.` },
|
||||
{ label: 'Activity', href: group?.urls?.studio_activity, icon: 'fa-solid fa-wave-square', tone: 'sky', detail: `${Number(dashboard.activity_count || 0)} feed items recorded.` },
|
||||
{ label: 'Edit profile', href: group?.urls?.studio_settings, icon: 'fa-solid fa-pen-to-square', tone: 'amber', detail: 'Update headline, visuals, and links.' },
|
||||
{ label: 'Recruitment', href: group?.urls?.studio_recruitment, icon: 'fa-solid fa-user-plus', tone: 'emerald', detail: recruitment?.is_recruiting ? 'Recruitment is live.' : 'Configure recruiting status.' },
|
||||
{ label: 'Create collection', href: group?.urls?.collection_create, icon: 'fa-solid fa-layer-group', tone: 'violet', detail: 'Publish a new collection under this group.' },
|
||||
].filter((item) => Boolean(item.href))
|
||||
|
||||
const toneClasses = {
|
||||
sky: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-300/10 text-amber-100',
|
||||
violet: 'border-violet-300/20 bg-violet-300/10 text-violet-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard label="Artworks" value={group?.counts?.artworks} icon="fa-solid fa-images" />
|
||||
<StatCard label="Collections" value={group?.counts?.collections} icon="fa-solid fa-layer-group" />
|
||||
<StatCard label="Followers" value={group?.counts?.followers} icon="fa-solid fa-user-group" />
|
||||
<StatCard label="Active members" value={dashboard?.active_members_count || group?.counts?.members} icon="fa-solid fa-people-group" />
|
||||
<StatCard label="Projects" value={dashboard?.projects_count} icon="fa-solid fa-diagram-project" />
|
||||
<StatCard label="Releases" value={dashboard?.published_releases_count || dashboard?.releases_count} icon="fa-solid fa-rocket" />
|
||||
<StatCard label="Assets" value={dashboard?.assets_count} icon="fa-solid fa-box-archive" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Quick actions</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Run the most common group tasks without leaving the dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{quickActions.map((action) => (
|
||||
<a key={action.label} href={action.href} className={`rounded-[24px] border px-4 py-4 transition hover:translate-y-[-1px] hover:border-white/20 ${toneClasses[action.tone] || toneClasses.sky}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-current/20 bg-black/10"><i className={action.icon} /></span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{action.label}</div>
|
||||
<div className="mt-1 text-xs opacity-80">{action.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Pending action</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">Drafts and scheduled items that still need a publishing decision.</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-slate-300">
|
||||
<div>{Number(dashboard?.draft_artworks_count || 0)} drafts</div>
|
||||
<div>{Number(dashboard?.scheduled_artworks_count || 0)} scheduled</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{draftsPendingAction.length > 0 ? draftsPendingAction.map((artwork) => (
|
||||
<ContentCard key={artwork.id} item={artwork} fallbackLabel="Draft" />
|
||||
)) : <EmptyCard title="No drafts waiting" description="This group has no draft artworks waiting for review or completion right now." />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingJoinRequests.length > 0 ? (
|
||||
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Pending join requests</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">Applicants waiting for a review decision.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_join_requests ? <a href={group.urls.studio_join_requests} className="text-sm font-semibold text-sky-200">Open queue</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{pendingJoinRequests.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="font-semibold text-white">{item.user?.name || item.user?.username}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{item.desired_role_label || item.desired_role || 'Contributor'} • {item.created_at ? new Date(item.created_at).toLocaleDateString() : 'New'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold text-white">Members</h2>
|
||||
<a href={group?.urls?.studio_members} className="text-sm font-semibold text-sky-200">Manage</a>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(roleSummary).map(([role, count]) => (
|
||||
<div key={role} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{role}</div>
|
||||
<div className="mt-1 text-xl font-semibold text-white">{Number(count)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{members.slice(0, 6).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-11 w-11 rounded-2xl object-cover" /> : <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-white">{member.user?.name || member.user?.username}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recruitment</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{recruitment?.is_recruiting ? (recruitment.headline || 'Recruiting is active') : 'Recruitment is off'}</div>
|
||||
<p className="mt-2 text-sm text-slate-400">{recruitment?.description || 'Set open roles, skills, and contact instructions from the recruitment page.'}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Releases</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Track featured drops and current release pipelines.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_releases ? <a href={group.urls.studio_releases} className="text-sm font-semibold text-sky-200">Manage</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{recentReleases.length > 0 ? recentReleases.map((release) => (
|
||||
<ContentCard key={release.id} item={release} fallbackLabel="Release" />
|
||||
)) : <EmptyCard title="No releases yet" description="Create a release to track milestones, contributors, and publication status." />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Projects</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Recent structured releases and collaboration hubs.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_projects ? <a href={group.urls.studio_projects} className="text-sm font-semibold text-sky-200">Manage</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{recentProjects.length > 0 ? recentProjects.map((project) => (
|
||||
<ContentCard key={project.id} item={project} fallbackLabel="Project" />
|
||||
)) : <EmptyCard title="No projects yet" description="Create a project to bundle shared assets, linked artworks, and a release state." />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Challenges</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Current creative prompts and challenge arcs.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_challenges ? <a href={group.urls.studio_challenges} className="text-sm font-semibold text-sky-200">Manage</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{recentChallenges.length > 0 ? recentChallenges.map((challenge) => (
|
||||
<ContentCard key={challenge.id} item={challenge} fallbackLabel="Challenge" />
|
||||
)) : <EmptyCard title="No challenges yet" description="Launch a challenge to keep the group active between major releases." />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Trust summary</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Public-facing trust labels and internal contributor health snapshot.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_reputation ? <a href={group.urls.studio_reputation} className="text-sm font-semibold text-sky-200">Open dashboard</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">{trustSignals.map((signal) => <span key={signal.key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">{signal.label}</span>)}</div>
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contributors</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputationSummary?.counts?.contributors || 0)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Group badges</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputationSummary?.counts?.group_badges || 0)}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Contributor highlights</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Recent high-trust contributors and badge unlocks.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">{Array.isArray(reputationSummary?.top_contributors) && reputationSummary.top_contributors.length > 0 ? reputationSummary.top_contributors.slice(0, 4).map((entry) => <div key={entry.user?.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="font-semibold text-white">{entry.user?.name || entry.user?.username}</div><div className="mt-1 text-sm text-slate-400">{entry.summary || 'Contributor'}</div><div className="mt-2 text-xs text-slate-500">{entry.counts?.releases || 0} releases • {entry.counts?.credited_artworks || 0} artworks</div></div>) : <EmptyCard title="No contributor signals yet" description="Release and milestone activity will populate contributor reputation here." />}</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Recent artworks</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Latest published work released under this group identity.</p>
|
||||
</div>
|
||||
<a href={group?.urls?.studio_artworks} className="text-sm font-semibold text-sky-200">View all</a>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{recentArtworks.length > 0 ? recentArtworks.map((artwork) => (
|
||||
<ContentCard key={artwork.id} item={artwork} fallbackLabel="Published" />
|
||||
)) : <EmptyCard title="No published artworks yet" description="Publish the first group artwork to start building this feed." />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Events</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Upcoming or recently updated moments on the group timeline.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_events ? <a href={group.urls.studio_events} className="text-sm font-semibold text-sky-200">Manage</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{recentEvents.length > 0 ? recentEvents.map((event) => (
|
||||
<ContentCard key={event.id} item={event} fallbackLabel="Event" />
|
||||
)) : <EmptyCard title="No events yet" description="Schedule a launch, stream, or milestone to start the group timeline." />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Recent collections</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Collections most recently updated in this group workspace.</p>
|
||||
</div>
|
||||
<a href={group?.urls?.studio_collections} className="text-sm font-semibold text-sky-200">View all</a>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{recentCollections.length > 0 ? recentCollections.map((collection) => (
|
||||
<ContentCard key={collection.id} item={collection} fallbackLabel="Collection" />
|
||||
)) : <EmptyCard title="No collections yet" description="Create a collection to organize group work into campaigns, series, or themed sets." />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Activity feed</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Pinned and recent internal or public timeline items.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_activity ? <a href={group.urls.studio_activity} className="text-sm font-semibold text-sky-200">Open feed</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{recentActivity.length > 0 ? recentActivity.map((item) => (
|
||||
<ActivityCard key={item.id} item={item} />
|
||||
)) : <EmptyCard title="No activity items yet" description="Publishing projects, events, posts, and member milestones will populate this feed." />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Review queue</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Latest artwork submissions waiting for moderation.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_review ? <a href={group.urls.studio_review} className="text-sm font-semibold text-sky-200">Open queue</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{reviewQueuePreview.length > 0 ? reviewQueuePreview.map((item) => (
|
||||
<a key={item.id} href={item.urls?.edit} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="text-sm font-semibold text-white">{item.title}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.group_review_status}</div>
|
||||
</a>
|
||||
)) : <EmptyCard title="No pending reviews" description="Contributor submissions will appear here when they are sent for review." />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Recent posts</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Announcements and updates published from the group.</p>
|
||||
</div>
|
||||
{group?.urls?.studio_posts ? <a href={group.urls.studio_posts} className="text-sm font-semibold text-sky-200">Manage posts</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{recentPosts.length > 0 ? recentPosts.map((post) => (
|
||||
<a key={post.id} href={post.url} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{post.type}</div>
|
||||
<div className="mt-2 text-base font-semibold text-white">{post.title}</div>
|
||||
<p className="mt-2 text-sm text-slate-400">{post.excerpt || 'Open post'}</p>
|
||||
</a>
|
||||
)) : <EmptyCard title="No posts yet" description="Create the first group announcement to add a public news feed." />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Recent history</h2>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{recentHistory.length > 0 ? recentHistory.map((item) => (
|
||||
<div key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
|
||||
<div className="mt-2 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} • {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
|
||||
</div>
|
||||
)) : <EmptyCard title="No history yet" description="Audit events will appear here as members review requests, posts, and submissions." />}
|
||||
</div>
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
82
resources/js/Pages/Studio/StudioGroupEventEditor.jsx
Normal file
82
resources/js/Pages/Studio/StudioGroupEventEditor.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import { useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupEventEditor() {
|
||||
const { props } = usePage()
|
||||
const eventRecord = props.event || null
|
||||
const form = useForm({
|
||||
title: eventRecord?.title || '',
|
||||
summary: eventRecord?.summary || '',
|
||||
description: eventRecord?.description || '',
|
||||
event_type: eventRecord?.event_type || props.typeOptions?.[0]?.value || 'launch',
|
||||
visibility: eventRecord?.visibility || props.visibilityOptions?.[0]?.value || 'public',
|
||||
status: eventRecord?.status || props.statusOptions?.[0]?.value || 'draft',
|
||||
start_at: eventRecord?.start_at ? eventRecord.start_at.slice(0, 16) : '',
|
||||
end_at: eventRecord?.end_at ? eventRecord.end_at.slice(0, 16) : '',
|
||||
timezone: eventRecord?.timezone || 'UTC',
|
||||
location: eventRecord?.location || '',
|
||||
external_url: eventRecord?.external_url || '',
|
||||
linked_project_id: eventRecord?.linked_project?.id || '',
|
||||
linked_collection_id: eventRecord?.linked_collection?.id || '',
|
||||
linked_challenge_id: eventRecord?.linked_challenge?.id || '',
|
||||
is_featured: Boolean(eventRecord?.is_featured),
|
||||
cover_file: null,
|
||||
})
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const options = { forceFormData: true, preserveScroll: true }
|
||||
if (props.updateUrl) {
|
||||
form.post(props.updateUrl, { ...options, _method: 'patch' })
|
||||
return
|
||||
}
|
||||
form.post(props.storeUrl, options)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<form onSubmit={submit} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-4">
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Event title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Event description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.event_type} onChange={(event) => form.setData('event_type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.typeOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input value={form.data.timezone} onChange={(event) => form.setData('timezone', event.target.value)} placeholder="Timezone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={form.data.location} onChange={(event) => form.setData('location', event.target.value)} placeholder="Location" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<input value={form.data.external_url} onChange={(event) => form.setData('external_url', event.target.value)} placeholder="External link" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_challenge_id} onChange={(event) => form.setData('linked_challenge_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked challenge</option>
|
||||
{(props.challengeOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} /> Featured event</label>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Save event</button>
|
||||
</form>
|
||||
{props.publishUrl ? <form onSubmit={(event) => { event.preventDefault(); form.post(props.publishUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6"><button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">Publish event</button></form> : null}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
29
resources/js/Pages/Studio/StudioGroupEvents.jsx
Normal file
29
resources/js/Pages/Studio/StudioGroupEvents.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupEvents() {
|
||||
const { props } = usePage()
|
||||
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-slate-400">Events let the group announce launches, sessions, milestones, and time-based updates.</div>
|
||||
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create event</a> : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{items.length > 0 ? items.map((event) => (
|
||||
<a key={event.id} href={event.urls?.edit || event.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold text-white">{event.title}</h2>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{event.status}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-400">{event.summary || 'Event page'}</p>
|
||||
<div className="mt-4 text-xs text-slate-500">{event.start_at ? new Date(event.start_at).toLocaleString() : 'Unscheduled'} • {event.event_type}</div>
|
||||
</a>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No events yet.</div>}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
129
resources/js/Pages/Studio/StudioGroupInvitations.jsx
Normal file
129
resources/js/Pages/Studio/StudioGroupInvitations.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function formatInviteTimestamp(value) {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
return new Date(value).toLocaleString()
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export default function StudioGroupInvitations() {
|
||||
const { props } = usePage()
|
||||
const invitations = Array.isArray(props.invitations) ? props.invitations : []
|
||||
const activeMembers = Array.isArray(props.members) ? props.members.filter((member) => member.status === 'active') : []
|
||||
const [invite, setInvite] = useState({ username: '', role: 'contributor', note: '', expires_in_days: 7 })
|
||||
|
||||
const pendingInvites = useMemo(
|
||||
() => invitations.filter((item) => item.status === 'pending'),
|
||||
[invitations]
|
||||
)
|
||||
|
||||
const revokedInvites = useMemo(
|
||||
() => invitations.filter((item) => item.status === 'revoked'),
|
||||
[invitations]
|
||||
)
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75">Group invitations</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Invite collaborators into {props.studioGroup?.name}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">Pending invites stay separate from active members here, so owners and admins can review who was invited, when the invite expires, and revoke access before acceptance.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={props.studioGroup?.urls?.studio_members} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Members</Link>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">{pendingInvites.length} pending</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-[1.1fr_0.8fr_1fr_0.7fr_auto]">
|
||||
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={invite.expires_in_days} onChange={(event) => setInvite((current) => ({ ...current, expires_in_days: event.target.value }))} type="number" min="1" max="30" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={() => router.post(props.endpoints?.invite, { ...invite, expires_in_days: Number(invite.expires_in_days || 7) || 7 })} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Send invite</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Pending invitations</h2>
|
||||
<span className="text-sm text-slate-400">{pendingInvites.length} outstanding</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{pendingInvites.length > 0 ? pendingInvites.map((inviteRow) => (
|
||||
<article key={inviteRow.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
{inviteRow.user?.avatar_url ? <img src={inviteRow.user.avatar_url} alt={inviteRow.user.name || inviteRow.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div>
|
||||
<div className="font-semibold text-white">{inviteRow.user?.name || inviteRow.user?.username}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{inviteRow.role_label || inviteRow.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:ml-auto flex flex-wrap items-center gap-3 text-xs text-slate-400">
|
||||
{inviteRow.invited_by ? <span>Invited by {inviteRow.invited_by.name || inviteRow.invited_by.username}</span> : null}
|
||||
{inviteRow.invited_at ? <span>Sent {formatInviteTimestamp(inviteRow.invited_at)}</span> : null}
|
||||
{inviteRow.expires_at ? <span>Expires {formatInviteTimestamp(inviteRow.expires_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{inviteRow.note ? <p className="mt-3 text-sm text-slate-300">{inviteRow.note}</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{inviteRow.can_revoke && inviteRow.revoke_url ? <button type="button" onClick={() => router.delete(inviteRow.revoke_url)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100">Revoke invite</button> : null}
|
||||
</div>
|
||||
</article>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 px-6 py-12 text-center text-slate-400">No pending invites for this group.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Recent invite history</h2>
|
||||
<span className="text-sm text-slate-400">{revokedInvites.length} revoked or expired</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{revokedInvites.length > 0 ? revokedInvites.map((inviteRow) => (
|
||||
<article key={inviteRow.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="font-semibold text-white">{inviteRow.user?.name || inviteRow.user?.username}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{inviteRow.is_expired ? 'Expired' : 'Revoked'} • {inviteRow.role_label || inviteRow.role}</div>
|
||||
{inviteRow.invited_at ? <p className="mt-2 text-sm text-slate-400">Originally sent {formatInviteTimestamp(inviteRow.invited_at)}</p> : null}
|
||||
</article>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-slate-400">No recent invite history yet.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Active members</h2>
|
||||
<span className="text-sm text-slate-400">{activeMembers.length} active</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{activeMembers.slice(0, 6).map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-11 w-11 rounded-2xl object-cover" /> : <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-white">{member.user?.name || member.user?.username}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">{member.role_label || member.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
96
resources/js/Pages/Studio/StudioGroupJoinRequests.jsx
Normal file
96
resources/js/Pages/Studio/StudioGroupJoinRequests.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function HistoryList({ items }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No recent history yet.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} • {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioGroupJoinRequests() {
|
||||
const { props } = usePage()
|
||||
const listing = props.listing || {}
|
||||
const items = Array.isArray(listing.items) ? listing.items : []
|
||||
|
||||
const approve = (request) => {
|
||||
const role = window.prompt('Role to assign on approval? contributor, editor, or admin', request.desired_role || 'contributor') || request.desired_role || 'contributor'
|
||||
const notes = window.prompt('Optional approval note', '') || ''
|
||||
router.post(request.can_approve ? routeUrl(props.studioGroup?.urls?.studio_join_requests, request.id, 'approve') : '', { action: 'approve', role, review_notes: notes })
|
||||
}
|
||||
|
||||
const reject = (request) => {
|
||||
const notes = window.prompt('Optional rejection note', '') || ''
|
||||
router.post(request.can_reject ? routeUrl(props.studioGroup?.urls?.studio_join_requests, request.id, 'reject') : '', { action: 'reject', review_notes: notes })
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Incoming requests</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Approve, reject, and assign roles from one queue.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{listing.filters?.bucket || 'pending'}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.user?.avatar_url ? <img src={item.user.avatar_url} alt={item.user.name || item.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div>
|
||||
<div className="font-semibold text-white">{item.user?.name || item.user?.username}</div>
|
||||
<div className="text-sm text-slate-400">Requested role: {item.desired_role_label || item.desired_role || 'Contributor'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status}</span>
|
||||
</div>
|
||||
{item.message ? <p className="mt-4 text-sm leading-6 text-slate-300">{item.message}</p> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
{item.portfolio_url ? <a href={item.portfolio_url} className="text-sky-200 underline underline-offset-4">Portfolio</a> : null}
|
||||
{Array.isArray(item.skills) && item.skills.length > 0 ? <span>{item.skills.join(', ')}</span> : null}
|
||||
{item.created_at ? <span>{new Date(item.created_at).toLocaleString()}</span> : null}
|
||||
</div>
|
||||
{item.review_notes ? <p className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">{item.review_notes}</p> : null}
|
||||
{item.can_approve || item.can_reject ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.can_approve ? <button type="button" onClick={() => approve(item)} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
|
||||
{item.can_reject ? <button type="button" onClick={() => reject(item)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Reject</button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No join requests in this bucket.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Recent history</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Audit trail for moderation-sensitive group actions.</p>
|
||||
<div className="mt-4">
|
||||
<HistoryList items={props.recentHistory} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function routeUrl(baseUrl, id, action) {
|
||||
if (!baseUrl) return ''
|
||||
return `${String(baseUrl).replace(/\/$/, '')}/${id}/${action}`
|
||||
}
|
||||
193
resources/js/Pages/Studio/StudioGroupMembers.jsx
Normal file
193
resources/js/Pages/Studio/StudioGroupMembers.jsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function overrideMap(member) {
|
||||
const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : []
|
||||
|
||||
return entries.reduce((carry, item) => {
|
||||
if (!item?.key) return carry
|
||||
carry[item.key] = item.is_allowed === true ? 'allow' : 'deny'
|
||||
return carry
|
||||
}, {})
|
||||
}
|
||||
|
||||
function prettifyPermission(value) {
|
||||
return String(value || '').replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
export default function StudioGroupMembers() {
|
||||
const { props } = usePage()
|
||||
const canManageMembers = Boolean(props.canManageMembers)
|
||||
const [invite, setInvite] = useState({ username: '', role: 'contributor', note: '' })
|
||||
const [search, setSearch] = useState('')
|
||||
const [editingMemberId, setEditingMemberId] = useState(null)
|
||||
const [permissionDrafts, setPermissionDrafts] = useState({})
|
||||
const members = Array.isArray(props.members) ? props.members : []
|
||||
const permissionOptions = Array.isArray(props.permissionOverrideOptions) ? props.permissionOverrideOptions : []
|
||||
|
||||
const filteredMembers = members.filter((member) => {
|
||||
const haystack = `${member.user?.name || ''} ${member.user?.username || ''} ${member.role_label || member.role || ''}`.toLowerCase()
|
||||
|
||||
return haystack.includes(search.trim().toLowerCase())
|
||||
})
|
||||
|
||||
const confirmTransfer = (member) => {
|
||||
if (!window.confirm(`Transfer ownership of this group to ${member.user?.name || member.user?.username}? This removes your owner privileges immediately.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
router.post(props.endpoints?.transferPattern.replace('__MEMBER__', String(member.id)))
|
||||
}
|
||||
|
||||
const confirmRemoval = (member) => {
|
||||
if (!window.confirm(`Remove ${member.user?.name || member.user?.username} from this group?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
router.delete(props.endpoints?.deletePattern.replace('__MEMBER__', String(member.id)))
|
||||
}
|
||||
|
||||
const openPermissionEditor = (member) => {
|
||||
setEditingMemberId(member.id)
|
||||
setPermissionDrafts((current) => ({ ...current, [member.id]: overrideMap(member) }))
|
||||
}
|
||||
|
||||
const setPermissionState = (memberId, key, value) => {
|
||||
setPermissionDrafts((current) => ({
|
||||
...current,
|
||||
[memberId]: {
|
||||
...(current[memberId] || {}),
|
||||
[key]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const savePermissions = (member) => {
|
||||
const state = permissionDrafts[member.id] || {}
|
||||
const payload = permissionOptions
|
||||
.filter((option) => state[option.value] === 'allow' || state[option.value] === 'deny')
|
||||
.map((option) => ({ key: option.value, is_allowed: state[option.value] === 'allow' }))
|
||||
|
||||
router.patch(props.endpoints?.permissionsPattern.replace('__MEMBER__', String(member.id)), {
|
||||
permission_overrides: payload,
|
||||
}, {
|
||||
onSuccess: () => setEditingMemberId(null),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
{canManageMembers ? (
|
||||
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">Invite member</h2>
|
||||
{props.endpoints?.invitations ? <a href={props.endpoints.invitations} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage invitations</a> : null}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_0.8fr_1fr_auto]">
|
||||
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={() => router.post(props.endpoints?.invite, invite)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Invite</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Member directory</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Search the current roster, then adjust roles or membership status.</p>
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-300 md:min-w-[260px]">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search members</span>
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Name, username, or role" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-[24px] border border-white/10">
|
||||
<div className="hidden grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] gap-3 border-b border-white/10 bg-white/[0.04] px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 md:grid">
|
||||
<span>Member</span>
|
||||
<span>Role</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
<div className="divide-y divide-white/10">
|
||||
{filteredMembers.map((member) => (
|
||||
<article key={member.id} className="grid gap-4 px-4 py-4 md:grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] md:items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div>
|
||||
<div className="font-semibold text-white">{member.user?.name || member.user?.username}</div>
|
||||
<div className="text-sm text-slate-400">@{member.user?.username || 'member'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{canManageMembers && member.role !== 'owner' ? (
|
||||
<select value={member.role} onChange={(event) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: event.target.value })} className="w-full rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none">
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
) : <span className="inline-flex rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">{member.role === 'owner' ? 'Owner' : (member.role_label || member.role)}</span>}
|
||||
{Array.isArray(member.permission_overrides) && member.permission_overrides.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{member.permission_overrides.map((permission) => (
|
||||
<span key={`${permission.key}-${permission.is_allowed ? 'allow' : 'deny'}`} className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] ${permission.is_allowed ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
|
||||
{permission.is_allowed ? 'Allow' : 'Deny'} {prettifyPermission(permission.key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">{member.status}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{member.can_manage_permissions ? <button type="button" onClick={() => openPermissionEditor(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Permissions</button> : null}
|
||||
{canManageMembers && member.can_transfer ? <button type="button" onClick={() => confirmTransfer(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Transfer</button> : null}
|
||||
{canManageMembers && member.can_revoke ? <button type="button" onClick={() => confirmRemoval(member)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100">Remove</button> : null}
|
||||
</div>
|
||||
{editingMemberId === member.id ? (
|
||||
<div className="md:col-span-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Permission overrides</h3>
|
||||
<p className="mt-1 text-xs text-slate-400">Set each advanced capability to inherit, allow, or deny.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setEditingMemberId(null)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Cancel</button>
|
||||
<button type="button" onClick={() => savePermissions(member)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{permissionOptions.map((option) => {
|
||||
const current = permissionDrafts[member.id]?.[option.value] || 'inherit'
|
||||
|
||||
return (
|
||||
<div key={option.value} className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
|
||||
<div className="text-sm font-semibold text-white">{option.label}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'inherit')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'inherit' ? 'border-white/20 bg-white/[0.08] text-white' : 'border-white/10 bg-transparent text-slate-300'}`}>Inherit</button>
|
||||
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'allow')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'allow' ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Allow</button>
|
||||
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'deny')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'deny' ? 'border-rose-300/20 bg-rose-400/10 text-rose-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{filteredMembers.length === 0 ? <div className="px-4 py-8 text-sm text-slate-400">No members match the current search.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
64
resources/js/Pages/Studio/StudioGroupPostEditor.jsx
Normal file
64
resources/js/Pages/Studio/StudioGroupPostEditor.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupPostEditor() {
|
||||
const { props } = usePage()
|
||||
const post = props.post || {}
|
||||
const form = useForm({
|
||||
type: post.type || 'announcement',
|
||||
title: post.title || '',
|
||||
excerpt: post.excerpt || '',
|
||||
content: post.content || '',
|
||||
cover_path: post.cover_url || '',
|
||||
})
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
if (props.updateUrl) {
|
||||
form.patch(props.updateUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(props.storeUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
|
||||
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Content</span>
|
||||
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={12} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Post controls</h2>
|
||||
<div className="mt-5 space-y-3">
|
||||
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save</button>
|
||||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish</button> : null}
|
||||
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
|
||||
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive</button> : null}
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
44
resources/js/Pages/Studio/StudioGroupPosts.jsx
Normal file
44
resources/js/Pages/Studio/StudioGroupPosts.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupPosts() {
|
||||
const { props } = usePage()
|
||||
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Post library</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Draft, publish, pin, and archive public group posts.</p>
|
||||
</div>
|
||||
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">New post</a> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.type}</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">{item.title}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.status}</span>
|
||||
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-amber-100">Pinned</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">{item.excerpt || item.content || 'No excerpt yet.'}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<a href={item.urls?.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</a>
|
||||
{item.urls?.public ? <a href={item.urls.public} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">View</a> : null}
|
||||
</div>
|
||||
</article>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No posts yet.</div>}
|
||||
</div>
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
143
resources/js/Pages/Studio/StudioGroupProjectEditor.jsx
Normal file
143
resources/js/Pages/Studio/StudioGroupProjectEditor.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function normalizeIds(values) {
|
||||
return Array.from(values || []).map((option) => Number(option.value)).filter((value) => Number.isFinite(value) && value > 0)
|
||||
}
|
||||
|
||||
export default function StudioGroupProjectEditor() {
|
||||
const { props } = usePage()
|
||||
const project = props.project || null
|
||||
const form = useForm({
|
||||
title: project?.title || '',
|
||||
summary: project?.summary || '',
|
||||
description: project?.description || '',
|
||||
visibility: project?.visibility || props.visibilityOptions?.[0]?.value || 'public',
|
||||
status: project?.status || props.statusOptions?.[0]?.value || 'planned',
|
||||
start_date: project?.start_date || '',
|
||||
target_date: project?.target_date || '',
|
||||
lead_user_id: project?.lead?.id || '',
|
||||
linked_collection_id: project?.linked_collection?.id || '',
|
||||
linked_featured_artwork_id: '',
|
||||
pinned_post_id: project?.pinned_post?.id || '',
|
||||
member_user_ids: Array.isArray(project?.team) ? project.team.map((member) => member.id) : [],
|
||||
cover_file: null,
|
||||
})
|
||||
const artworkAttach = useForm({ artwork_id: '' })
|
||||
const assetAttach = useForm({ asset_id: '' })
|
||||
const statusForm = useForm({ status: project?.status || props.statusOptions?.[0]?.value || 'planned' })
|
||||
const milestoneForm = useForm({ title: '', summary: '', status: 'pending', due_date: '', owner_user_id: '', notes: '' })
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const options = { forceFormData: true, preserveScroll: true }
|
||||
if (props.updateUrl) {
|
||||
form.post(props.updateUrl, { ...options, _method: 'patch' })
|
||||
return
|
||||
}
|
||||
form.post(props.storeUrl, options)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-4">
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Project title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Longer project description" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input type="date" value={form.data.start_date} onChange={(event) => form.setData('start_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input type="date" value={form.data.target_date} onChange={(event) => form.setData('target_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No lead</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<select multiple value={form.data.member_user_ids.map(String)} onChange={(event) => form.setData('member_user_ids', normalizeIds(event.target.selectedOptions))} className="min-h-40 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.linked_featured_artwork_id} onChange={(event) => form.setData('linked_featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No featured artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.pinned_post_id} onChange={(event) => form.setData('pinned_post_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No pinned post</option>
|
||||
{(props.postOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<button type="submit" disabled={form.processing} className="mt-6 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">{form.processing ? 'Saving…' : 'Save project'}</button>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
{props.statusUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); statusForm.post(props.statusUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Status</h2>
|
||||
<select value={statusForm.data.status} onChange={(event) => statusForm.setData('status', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update status</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{props.attachArtworkUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
|
||||
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{props.attachAssetUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); assetAttach.post(props.attachAssetUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach asset</h2>
|
||||
<select value={assetAttach.data.asset_id} onChange={(event) => assetAttach.setData('asset_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose asset</option>
|
||||
{(props.assetOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach asset</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{props.storeMilestoneUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset('title', 'summary', 'due_date', 'owner_user_id', 'notes') }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Milestones</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No owner</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
|
||||
</div>
|
||||
{Array.isArray(project?.milestones) && project.milestones.length > 0 ? <div className="mt-6 space-y-3">{project.milestones.map((milestone) => <div key={milestone.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="flex items-center justify-between gap-3"><div><div className="font-semibold text-white">{milestone.title}</div><div className="mt-1 text-xs text-slate-500">{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}</div></div><button type="button" onClick={() => router.patch(props.updateMilestonePattern.replace('__MILESTONE__', String(milestone.id)), { title: milestone.title, summary: milestone.summary || '', status: milestone.status === 'completed' ? 'active' : 'completed', due_date: milestone.due_date || '', owner_user_id: milestone.owner?.id || '', notes: milestone.notes || '' }, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">Mark {milestone.status === 'completed' ? 'active' : 'complete'}</button></div>{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}</div>)}</div> : null}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
30
resources/js/Pages/Studio/StudioGroupProjects.jsx
Normal file
30
resources/js/Pages/Studio/StudioGroupProjects.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupProjects() {
|
||||
const { props } = usePage()
|
||||
const listing = props.listing || {}
|
||||
const items = Array.isArray(listing.items) ? listing.items : []
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-slate-400">Projects give the group a structured place for releases, teams, and linked outputs.</div>
|
||||
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create project</a> : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{items.length > 0 ? items.map((project) => (
|
||||
<a key={project.id} href={project.urls?.edit || project.url} className="rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold text-white">{project.title}</h2>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{project.status}</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-400">{project.summary || 'Project page'}</p>
|
||||
<div className="mt-4 text-xs text-slate-500">{project.counts?.artworks || 0} artworks • {project.counts?.assets || 0} assets • {project.counts?.team || 0} team • {project.counts?.milestones || 0} milestones • {project.counts?.releases || 0} releases</div>
|
||||
</a>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No projects yet.</div>}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
101
resources/js/Pages/Studio/StudioGroupRecruitment.jsx
Normal file
101
resources/js/Pages/Studio/StudioGroupRecruitment.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react'
|
||||
import { useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function toggleItem(list, value) {
|
||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
|
||||
}
|
||||
|
||||
export default function StudioGroupRecruitment() {
|
||||
const { props } = usePage()
|
||||
const recruitment = props.recruitment || {}
|
||||
const form = useForm({
|
||||
is_recruiting: Boolean(recruitment.is_recruiting),
|
||||
headline: recruitment.headline || '',
|
||||
description: recruitment.description || '',
|
||||
roles_json: Array.isArray(recruitment.roles) ? recruitment.roles : [],
|
||||
skills_json: Array.isArray(recruitment.skills) ? recruitment.skills : [],
|
||||
contact_mode: recruitment.contact_mode || 'join_request',
|
||||
visibility: recruitment.visibility || 'public',
|
||||
})
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
form.patch(props.updateUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Recruitment profile</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Describe what the group is looking for and how applicants should reach you.</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_recruiting} onChange={(event) => form.setData('is_recruiting', event.target.checked)} />
|
||||
Recruiting now
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Headline</span>
|
||||
<input value={form.data.headline} onChange={(event) => form.setData('headline', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Description</span>
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={7} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Roles</span>
|
||||
<div className="flex flex-wrap gap-2 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
{(Array.isArray(props.roleOptions) ? props.roleOptions : []).map((option) => {
|
||||
const selected = form.data.roles_json.includes(option.value)
|
||||
|
||||
return <button key={option.value} type="button" onClick={() => form.setData('roles_json', toggleItem(form.data.roles_json, option.value))} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${selected ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-white/10 bg-white/[0.03] text-slate-300'}`}>{option.label}</button>
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Skills</span>
|
||||
<div className="flex flex-wrap gap-2 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
{(Array.isArray(props.skillOptions) ? props.skillOptions : []).map((option) => {
|
||||
const selected = form.data.skills_json.includes(option.value)
|
||||
|
||||
return <button key={option.value} type="button" onClick={() => form.setData('skills_json', toggleItem(form.data.skills_json, option.value))} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${selected ? 'border-sky-300/20 bg-sky-300/10 text-sky-100' : 'border-white/10 bg-white/[0.03] text-slate-300'}`}>{option.label}</button>
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Application settings</h2>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contact mode</span>
|
||||
<select value={form.data.contact_mode} onChange={(event) => form.setData('contact_mode', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.contactModes) ? props.contactModes : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</span>
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.visibilityOptions) ? props.visibilityOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Public preview</p>
|
||||
<p className="mt-2">{form.data.headline || 'No headline yet.'}</p>
|
||||
<p className="mt-2 text-slate-400">{form.data.description || 'Recruitment copy will show here once you add it.'}</p>
|
||||
{form.data.roles_json.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{form.data.roles_json.map((role) => <span key={role} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-white">{role}</span>)}</div> : null}
|
||||
</div>
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save recruitment profile</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
154
resources/js/Pages/Studio/StudioGroupReleaseEditor.jsx
Normal file
154
resources/js/Pages/Studio/StudioGroupReleaseEditor.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function toDateTimeInput(value) {
|
||||
return value ? String(value).slice(0, 16) : ''
|
||||
}
|
||||
|
||||
export default function StudioGroupReleaseEditor() {
|
||||
const { props } = usePage()
|
||||
const release = props.release || null
|
||||
const form = useForm({
|
||||
title: release?.title || '',
|
||||
summary: release?.summary || '',
|
||||
description: release?.description || '',
|
||||
release_notes: release?.release_notes || '',
|
||||
visibility: release?.visibility || props.visibilityOptions?.[0]?.value || 'public',
|
||||
status: release?.status || props.statusOptions?.[0]?.value || 'draft',
|
||||
current_stage: release?.current_stage || props.stageOptions?.[0]?.value || 'concept',
|
||||
planned_release_at: toDateTimeInput(release?.planned_release_at),
|
||||
lead_user_id: release?.lead?.id || '',
|
||||
linked_project_id: release?.linked_project?.id || '',
|
||||
linked_collection_id: release?.linked_collection?.id || '',
|
||||
featured_artwork_id: release?.featured_artwork?.id || '',
|
||||
is_featured: Boolean(release?.is_featured),
|
||||
cover_file: null,
|
||||
})
|
||||
const stageForm = useForm({ current_stage: release?.current_stage || props.stageOptions?.[0]?.value || 'concept' })
|
||||
const artworkAttach = useForm({ artwork_id: '' })
|
||||
const contributorForm = useForm({ user_id: '', role_label: '' })
|
||||
const milestoneForm = useForm({ title: '', summary: '', status: 'pending', due_date: '', owner_user_id: '', notes: '' })
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const options = { forceFormData: true, preserveScroll: true }
|
||||
if (props.updateUrl) {
|
||||
form.transform((data) => ({ ...data, _method: 'patch' })).post(props.updateUrl, options)
|
||||
return
|
||||
}
|
||||
form.post(props.storeUrl, options)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-4">
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Release title" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} placeholder="Short summary" rows={3} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} placeholder="Release overview" rows={8} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={form.data.release_notes} onChange={(event) => form.setData('release_notes', event.target.value)} placeholder="Release notes" rows={7} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<select value={form.data.visibility} onChange={(event) => form.setData('visibility', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.statusOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<select value={form.data.current_stage} onChange={(event) => form.setData('current_stage', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
</div>
|
||||
<input type="datetime-local" value={form.data.planned_release_at} onChange={(event) => form.setData('planned_release_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.lead_user_id} onChange={(event) => form.setData('lead_user_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No release lead</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<select value={form.data.linked_project_id} onChange={(event) => form.setData('linked_project_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked project</option>
|
||||
{(props.projectOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<select value={form.data.linked_collection_id} onChange={(event) => form.setData('linked_collection_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No linked collection</option>
|
||||
{(props.collectionOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<select value={form.data.featured_artwork_id} onChange={(event) => form.setData('featured_artwork_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No featured artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
|
||||
Feature this release on the public group page
|
||||
</label>
|
||||
<input type="file" accept="image/*" onChange={(event) => form.setData('cover_file', event.target.files?.[0] || null)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white">{form.processing ? 'Saving…' : 'Save release'}</button>
|
||||
{release?.url ? <a href={release.url} className="rounded-full border border-white/10 bg-black/20 px-5 py-2.5 text-sm font-semibold text-white">View public page</a> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
{props.stageUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); stageForm.post(props.stageUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Stage</h2>
|
||||
<select value={stageForm.data.current_stage} onChange={(event) => stageForm.setData('current_stage', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.stageOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Update stage</button>
|
||||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl, {}, { preserveScroll: true })} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Publish</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{props.attachArtworkUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); artworkAttach.post(props.attachArtworkUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Attach artwork</h2>
|
||||
<select value={artworkAttach.data.artwork_id} onChange={(event) => artworkAttach.setData('artwork_id', event.target.value)} className="mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose artwork</option>
|
||||
{(props.artworkOptions || []).map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
<button type="submit" className="mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach artwork</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{props.attachContributorUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); contributorForm.post(props.attachContributorUrl, { preserveScroll: true }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Contributor credit</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<select value={contributorForm.data.user_id} onChange={(event) => contributorForm.setData('user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Choose contributor</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<input value={contributorForm.data.role_label} onChange={(event) => contributorForm.setData('role_label', event.target.value)} placeholder="Role label" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Attach contributor</button>
|
||||
</div>
|
||||
{Array.isArray(release?.contributors) && release.contributors.length > 0 ? <div className="mt-6 space-y-3">{release.contributors.map((contributor) => <div key={contributor.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white"><div className="font-semibold">{contributor.name || contributor.username}</div><div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{contributor.role_label || 'Contributor'}</div></div>)}</div> : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{props.storeMilestoneUrl ? (
|
||||
<form onSubmit={(event) => { event.preventDefault(); milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset('title', 'summary', 'due_date', 'owner_user_id', 'notes') }) }} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Milestones</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<input value={milestoneForm.data.title} onChange={(event) => milestoneForm.setData('title', event.target.value)} placeholder="Milestone title" className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select value={milestoneForm.data.status} onChange={(event) => milestoneForm.setData('status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{['pending', 'active', 'blocked', 'completed', 'cancelled'].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<select value={milestoneForm.data.owner_user_id} onChange={(event) => milestoneForm.setData('owner_user_id', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No owner</option>
|
||||
{(props.memberOptions || []).map((option) => <option key={option.id} value={option.id}>{option.name || option.username}</option>)}
|
||||
</select>
|
||||
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="submit" className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Add milestone</button>
|
||||
</div>
|
||||
{Array.isArray(release?.milestones) && release.milestones.length > 0 ? <div className="mt-6 space-y-3">{release.milestones.map((milestone) => <div key={milestone.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="flex items-center justify-between gap-3"><div><div className="font-semibold text-white">{milestone.title}</div><div className="mt-1 text-xs text-slate-500">{milestone.owner?.name || milestone.owner?.username || 'No owner'}{milestone.due_date ? ` • due ${milestone.due_date}` : ''}</div></div><button type="button" onClick={() => router.patch(props.updateMilestonePattern.replace('__MILESTONE__', String(milestone.id)), { title: milestone.title, summary: milestone.summary || '', status: milestone.status === 'completed' ? 'active' : 'completed', due_date: milestone.due_date || '', owner_user_id: milestone.owner?.id || '', notes: milestone.notes || '' }, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">Mark {milestone.status === 'completed' ? 'active' : 'complete'}</button></div>{milestone.summary ? <p className="mt-2 text-sm text-slate-400">{milestone.summary}</p> : null}</div>)}</div> : null}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
47
resources/js/Pages/Studio/StudioGroupReleases.jsx
Normal file
47
resources/js/Pages/Studio/StudioGroupReleases.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupReleases() {
|
||||
const { props } = usePage()
|
||||
const listing = props.listing || {}
|
||||
const items = Array.isArray(listing.items) ? listing.items : []
|
||||
const bucketOptions = Array.isArray(listing.bucket_options) ? listing.bucket_options : []
|
||||
const currentBucket = listing.filters?.bucket || 'all'
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="text-sm text-slate-400">Track the release pipeline from draft through public launch, with milestones and contributor credits.</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={currentBucket} onChange={(event) => router.get(window.location.pathname, { bucket: event.target.value }, { preserveScroll: true, preserveState: true })} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm text-white outline-none">
|
||||
{bucketOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
{props.createUrl ? <a href={props.createUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Create release</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{items.length > 0 ? items.map((release) => (
|
||||
<div key={release.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.03]">
|
||||
{release.cover_url ? <img src={release.cover_url} alt={release.title} className="aspect-[4/3] w-full object-cover" /> : <div className="flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500"><i className="fa-solid fa-rocket text-2xl" /></div>}
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.status}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.current_stage}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{release.visibility}</span>
|
||||
</div>
|
||||
<h2 className="mt-3 text-xl font-semibold text-white">{release.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{release.summary || 'Release page'}</p>
|
||||
<div className="mt-4 text-xs text-slate-500">{release.counts?.artworks || 0} artworks • {release.counts?.contributors || 0} contributors • {release.counts?.milestones || 0} milestones</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<a href={release.urls?.edit || release.url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage</a>
|
||||
{release.urls?.public ? <a href={release.urls.public} className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-white">View public</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No releases yet.</div>}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
105
resources/js/Pages/Studio/StudioGroupReputation.jsx
Normal file
105
resources/js/Pages/Studio/StudioGroupReputation.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function MetricCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{Number(value || 0).toFixed(1)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioGroupReputation() {
|
||||
const { props } = usePage()
|
||||
const reputation = props.reputation || {}
|
||||
const trustSignals = Array.isArray(props.trustSignals) ? props.trustSignals : []
|
||||
const metrics = props.metrics || {}
|
||||
const topContributors = Array.isArray(reputation.top_contributors) ? reputation.top_contributors : []
|
||||
const recentBadges = Array.isArray(reputation.recent_badges) ? reputation.recent_badges : []
|
||||
const memberBadgeUnlocks = Array.isArray(reputation.member_badge_unlocks) ? reputation.member_badge_unlocks : []
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricCard label="Freshness" value={metrics.freshness_score} />
|
||||
<MetricCard label="Activity" value={metrics.activity_score} />
|
||||
<MetricCard label="Release" value={metrics.release_score} />
|
||||
<MetricCard label="Trust" value={metrics.trust_score} />
|
||||
<MetricCard label="Collaboration" value={metrics.collaboration_score} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Trust signals</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Public-safe labels that shape discovery and confidence.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">{trustSignals.map((signal) => <span key={signal.key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">{signal.label}</span>)}</div>
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Contributors</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputation.counts?.contributors || 0)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Member badges</div><div className="mt-2 text-2xl font-semibold text-white">{Number(reputation.counts?.member_badges || 0)}</div></div>
|
||||
</div>
|
||||
{metrics.last_calculated_at ? <div className="mt-4 text-xs text-slate-500">Last calculated {new Date(metrics.last_calculated_at).toLocaleString()}</div> : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Top contributors</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Reputation summaries derived from visible collaboration history.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{topContributors.length > 0 ? topContributors.map((entry) => (
|
||||
<div key={entry.user?.id} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{entry.user?.avatar_url ? <img src={entry.user.avatar_url} alt={entry.user?.name || entry.user?.username} className="h-11 w-11 rounded-2xl object-cover" /> : <div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate font-semibold text-white">{entry.user?.name || entry.user?.username}</div>
|
||||
{entry.trusted_indicator ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Trusted</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{entry.summary || 'Contributor'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">{entry.counts?.releases || 0} releases • {entry.counts?.projects || 0} projects • {entry.counts?.credited_artworks || 0} artworks • {entry.counts?.review_actions || 0} reviews</div>
|
||||
{Array.isArray(entry.badges) && entry.badges.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{entry.badges.map((badge) => <span key={`${entry.user?.id}-${badge.key}`} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{badge.label}</span>)}</div> : null}
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No contributor reputation signals yet.</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Group badges</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{recentBadges.length > 0 ? recentBadges.map((badge) => (
|
||||
<div key={badge.key} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="font-semibold text-white">{badge.label}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">{badge.reason}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No group badges awarded yet.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Recent member badge unlocks</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{memberBadgeUnlocks.length > 0 ? memberBadgeUnlocks.map((entry) => (
|
||||
<div key={`${entry.user?.id}-${entry.badge?.key}`} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="font-semibold text-white">{entry.user?.name || entry.user?.username}</div>
|
||||
<div className="mt-1 text-sm text-sky-200">{entry.badge?.label}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">{entry.badge?.reason}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No member badge unlocks yet.</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
74
resources/js/Pages/Studio/StudioGroupReviewQueue.jsx
Normal file
74
resources/js/Pages/Studio/StudioGroupReviewQueue.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function actionUrl(item, key) {
|
||||
return item?.urls?.[key] || ''
|
||||
}
|
||||
|
||||
export default function StudioGroupReviewQueue() {
|
||||
const { props } = usePage()
|
||||
const listing = props.listing || {}
|
||||
const items = Array.isArray(listing.items) ? listing.items : []
|
||||
|
||||
const sendAction = (item, action) => {
|
||||
const notes = window.prompt('Optional reviewer note', '') || ''
|
||||
router.post(actionUrl(item, action), { action, review_notes: notes })
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Submission queue</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Review artwork drafts before they publish under the group identity.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{listing.filters?.bucket || 'submitted'}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<article key={item.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
{item.thumb ? <img src={item.thumb} alt={item.title} className="h-24 w-24 rounded-2xl object-cover" /> : <div className="flex h-24 w-24 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-image" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300">{item.group_review_status}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
{item.primary_author ? <span>Author: {item.primary_author.name || item.primary_author.username}</span> : null}
|
||||
{item.uploader ? <span>Uploader: {item.uploader.name || item.uploader.username}</span> : null}
|
||||
{item.submitted_at ? <span>Submitted {new Date(item.submitted_at).toLocaleString()}</span> : null}
|
||||
</div>
|
||||
{item.group_review_notes ? <p className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">{item.group_review_notes}</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<a href={item.urls?.edit} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Open draft</a>
|
||||
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'approve')} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
|
||||
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'needs_changes')} className="rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100">Needs changes</button> : null}
|
||||
{item.can_review ? <button type="button" onClick={() => sendAction(item, 'reject')} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Reject</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No submissions in this bucket.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Recent history</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(Array.isArray(props.recentHistory) ? props.recentHistory : []).map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-white">{item.summary || item.action_type}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{item.actor?.name || item.actor?.username || 'System'} • {item.created_at ? new Date(item.created_at).toLocaleString() : 'Recently'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
170
resources/js/Pages/Studio/StudioGroupSettings.jsx
Normal file
170
resources/js/Pages/Studio/StudioGroupSettings.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
export default function StudioGroupSettings() {
|
||||
const { props } = usePage()
|
||||
const group = props.studioGroup || {}
|
||||
const featuredArtworkOptions = Array.isArray(props.featuredArtworkOptions) ? props.featuredArtworkOptions : []
|
||||
const avatarInputRef = useRef(null)
|
||||
const bannerInputRef = useRef(null)
|
||||
const [form, setForm] = useState({
|
||||
name: group.name || '',
|
||||
slug: group.slug || '',
|
||||
headline: group.headline || '',
|
||||
bio: group.bio || '',
|
||||
type: group.type || '',
|
||||
founded_at: group.founded_at ? String(group.founded_at).slice(0, 10) : '',
|
||||
avatar_path: group.avatar_path || group.avatar_url || '',
|
||||
banner_path: group.banner_path || group.banner_url || '',
|
||||
visibility: group.visibility || 'public',
|
||||
membership_policy: group.membership_policy || 'invite_only',
|
||||
website_url: group.website_url || '',
|
||||
links_json: Array.isArray(group.links) && group.links.length > 0 ? group.links : [{ label: '', url: '' }],
|
||||
featured_artwork_id: group.featured_artwork_id || '',
|
||||
avatar_file: null,
|
||||
banner_file: null,
|
||||
})
|
||||
const [avatarPreview, setAvatarPreview] = useState('')
|
||||
const [bannerPreview, setBannerPreview] = useState('')
|
||||
|
||||
const selectedFeaturedArtwork = useMemo(
|
||||
() => featuredArtworkOptions.find((item) => Number(item.id) === Number(form.featured_artwork_id)) || null,
|
||||
[featuredArtworkOptions, form.featured_artwork_id],
|
||||
)
|
||||
|
||||
const updateLink = (index, key, value) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
links_json: current.links_json.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item),
|
||||
}))
|
||||
}
|
||||
|
||||
const addLink = () => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
links_json: [...current.links_json, { label: '', url: '' }],
|
||||
}))
|
||||
}
|
||||
|
||||
const removeLink = (index) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
links_json: current.links_json.filter((_, itemIndex) => itemIndex !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
router.post(props.endpoints?.update, {
|
||||
_method: 'patch',
|
||||
...form,
|
||||
links_json: form.links_json.filter((item) => item.label.trim() !== '' || item.url.trim() !== ''),
|
||||
}, {
|
||||
forceFormData: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleFileSelected = (field, setPreview) => (event) => {
|
||||
const file = event.target.files?.[0] || null
|
||||
|
||||
setForm((current) => ({ ...current, [field]: file }))
|
||||
setPreview(file ? URL.createObjectURL(file) : '')
|
||||
}
|
||||
|
||||
const clearSelectedFile = (field, setPreview, inputRef) => {
|
||||
setForm((current) => ({ ...current, [field]: null }))
|
||||
setPreview('')
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const archiveGroup = () => {
|
||||
if (!window.confirm('Archive this group? New group publishing will stop immediately until you reopen it through admin tooling.')) {
|
||||
return
|
||||
}
|
||||
|
||||
router.post(props.endpoints?.archive)
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<section className="mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<div className="grid gap-5">
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Name</span><input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Slug</span><input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Short description</span><input value={form.headline} onChange={(event) => setForm((current) => ({ ...current, headline: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>About</span><textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Type / category</span><input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Founded date</span><input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Website</span><input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<span className="text-sm font-semibold text-white">Avatar / logo</span>
|
||||
<div className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
|
||||
{avatarPreview || form.avatar_path || group.avatar_url ? <img src={avatarPreview || form.avatar_path || group.avatar_url} alt="Avatar preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-image text-slate-500" />}
|
||||
</div>
|
||||
<input ref={avatarInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('avatar_file', setAvatarPreview)} className="hidden" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => avatarInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload avatar</button>
|
||||
{form.avatar_file ? <button type="button" onClick={() => clearSelectedFile('avatar_file', setAvatarPreview, avatarInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use current path</button> : null}
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Or paste an image URL</span><input value={form.avatar_path} onChange={(event) => setForm((current) => ({ ...current, avatar_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<span className="text-sm font-semibold text-white">Cover image</span>
|
||||
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]">
|
||||
{bannerPreview || form.banner_path || group.banner_url ? <img src={bannerPreview || form.banner_path || group.banner_url} alt="Cover preview" className="h-full w-full object-cover" /> : <i className="fa-solid fa-panorama text-slate-500" />}
|
||||
</div>
|
||||
<input ref={bannerInputRef} type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileSelected('banner_file', setBannerPreview)} className="hidden" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => bannerInputRef.current?.click()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Upload cover</button>
|
||||
{form.banner_file ? <button type="button" onClick={() => clearSelectedFile('banner_file', setBannerPreview, bannerInputRef)} className="rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300">Use current path</button> : null}
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Or paste an image URL</span><input value={form.banner_path} onChange={(event) => setForm((current) => ({ ...current, banner_path: event.target.value }))} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<label className="grid gap-2 text-sm text-slate-200">
|
||||
<span>Featured artwork</span>
|
||||
<select value={form.featured_artwork_id} onChange={(event) => setForm((current) => ({ ...current, featured_artwork_id: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">Use latest published artwork</option>
|
||||
{featuredArtworkOptions.map((item) => <option key={item.id} value={item.id}>{item.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{selectedFeaturedArtwork ? (
|
||||
<div className="flex items-center gap-3 rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
|
||||
{selectedFeaturedArtwork.thumb ? <img src={selectedFeaturedArtwork.thumb} alt={selectedFeaturedArtwork.title} className="h-16 w-16 rounded-2xl object-cover" /> : null}
|
||||
<div>
|
||||
<div className="font-semibold text-white">{selectedFeaturedArtwork.title}</div>
|
||||
<div className="text-sm text-slate-400">{selectedFeaturedArtwork.author || 'Group member'}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">When this is empty, the public overview falls back to the latest published works automatically.</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Visibility</span><select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.visibilityOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
||||
<label className="grid gap-2 text-sm text-slate-200"><span>Membership policy</span><select value={form.membership_policy} onChange={(event) => setForm((current) => ({ ...current, membership_policy: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">{(props.membershipPolicyOptions || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-slate-200">Links</span>
|
||||
<button type="button" onClick={addLink} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Add link</button>
|
||||
</div>
|
||||
{form.links_json.map((item, index) => (
|
||||
<div key={`link-${index}`} className="grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]">
|
||||
<input value={item.label} onChange={(event) => updateLink(index, 'label', event.target.value)} placeholder="Label" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={item.url} onChange={(event) => updateLink(index, 'url', event.target.value)} placeholder="https://" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={() => removeLink(index)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between gap-3"><button type="button" onClick={archiveGroup} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Archive group</button><button type="button" onClick={submit} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Save settings</button></div>
|
||||
</div>
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
91
resources/js/Pages/Studio/StudioGroupsIndex.jsx
Normal file
91
resources/js/Pages/Studio/StudioGroupsIndex.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
|
||||
|
||||
function GroupCard({ group }) {
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(3,7,18,0.22)]">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-slate-900/70 text-slate-300">
|
||||
{group.avatar_url ? <img src={group.avatar_url} alt={group.name} className="h-full w-full object-cover" /> : <i className="fa-solid fa-people-group" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate text-lg font-semibold text-white">{group.name}</h2>
|
||||
{group.viewer?.role ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">{group.viewer.role}</span> : null}
|
||||
{Number(group.pending_invites_count || 0) > 0 ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">{Number(group.pending_invites_count)} pending invite{Number(group.pending_invites_count) === 1 ? '' : 's'}</span> : null}
|
||||
</div>
|
||||
{group.headline ? <p className="mt-2 text-sm text-slate-300">{group.headline}</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
|
||||
<span>{Number(group.counts?.artworks || 0).toLocaleString()} artworks</span>
|
||||
<span>{Number(group.counts?.collections || 0).toLocaleString()} collections</span>
|
||||
<span>{Number(group.counts?.followers || 0).toLocaleString()} followers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<a href={group.urls?.studio} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">Open Studio</a>
|
||||
<a href={group.urls?.studio_invitations} className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]">Invitations</a>
|
||||
<a href={group.urls?.public} className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.06]">Public page</a>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioGroupsIndex() {
|
||||
const { props } = usePage()
|
||||
const groups = Array.isArray(props.groups) ? props.groups : []
|
||||
const pendingInvites = Array.isArray(props.pendingInvites) ? props.pendingInvites : []
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<GroupStudioPromoCard
|
||||
title="Publish as a team, not just an individual"
|
||||
description="Groups let you share ownership across artworks, releases, collections, reviews, and recruiting while keeping one public identity for the whole collective."
|
||||
bullets={[
|
||||
{ title: 'Shared publishing', body: 'Release under one name while keeping credited contributors visible across the artwork and group pages.' },
|
||||
{ title: 'Team workflow', body: 'Invite reviewers, managers, and contributors into one studio space with role-based permissions.' },
|
||||
{ title: 'Public discovery', body: 'Groups now appear across search, homepage modules, leaderboards, and public browse surfaces.' },
|
||||
]}
|
||||
primaryLabel="Create a group"
|
||||
primaryHref={props.endpoints?.create}
|
||||
secondaryLabel="Browse public groups"
|
||||
secondaryHref="/groups"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Collective publishing</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Launch and manage shared identities</h2>
|
||||
</div>
|
||||
<Link href={props.endpoints?.create} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15">Create group</Link>
|
||||
</div>
|
||||
|
||||
{pendingInvites.length > 0 ? (
|
||||
<section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 p-5">
|
||||
<h2 className="text-lg font-semibold text-amber-50">Pending invites</h2>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{pendingInvites.map((invite) => (
|
||||
<article key={invite.id} className="rounded-2xl border border-white/10 bg-black/20 p-4 text-white">
|
||||
<h3 className="text-base font-semibold">{invite.group?.name}</h3>
|
||||
<p className="mt-2 text-sm text-amber-50/80">Role: {invite.role}</p>
|
||||
{invite.invited_by ? <p className="mt-1 text-sm text-amber-50/70">Invited by {invite.invited_by.name || invite.invited_by.username}</p> : null}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button type="button" onClick={() => router.post(invite.accept_url)} className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">Accept</button>
|
||||
<button type="button" onClick={() => router.post(invite.decline_url)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Decline</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{groups.length > 0 ? groups.map((group) => <GroupCard key={group.slug} group={group} />) : (
|
||||
<div className="rounded-[28px] border border-dashed border-white/10 px-6 py-16 text-center text-slate-400">No groups yet. Create one to start publishing collaboratively.</div>
|
||||
)}
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
377
resources/js/Pages/Studio/StudioNewsEditor.jsx
Normal file
377
resources/js/Pages/Studio/StudioNewsEditor.jsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function SearchResultList({ items, onSelect, emptyLabel = 'No matches yet.' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-3 text-xs text-slate-500">{emptyLabel}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={`${item.entity_type}-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-3 py-3 text-left transition hover:border-white/20"
|
||||
>
|
||||
{item.avatar ? <img src={item.avatar} alt={item.title} className="h-10 w-10 rounded-2xl border border-white/10 object-cover" /> : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-1 text-xs text-slate-400 line-clamp-2">{item.description}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select value={relation.entity_type} onChange={(event) => onChange(index, { ...relation, entity_type: event.target.value, entity_id: '', preview: null, query: '' })} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{relationTypeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
|
||||
<div className="flex gap-2">
|
||||
<input value={relation.query || ''} onChange={(event) => onChange(index, { ...relation, query: event.target.value })} placeholder="Search by name, slug, or title" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={() => onSearch(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</label>
|
||||
<button type="button" onClick={() => onRemove(index)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Remove</button>
|
||||
</div>
|
||||
|
||||
{relation.preview ? (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="font-semibold">Linked: {relation.preview.title}</div>
|
||||
{relation.preview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{relation.preview.subtitle}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4">
|
||||
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
|
||||
</div>
|
||||
|
||||
<label className="mt-4 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
|
||||
<input value={relation.context_label || ''} onChange={(event) => onChange(index, { ...relation, context_label: event.target.value })} placeholder="Featured release, Meet the creator, Join this challenge…" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StudioNewsEditor() {
|
||||
const { props } = usePage()
|
||||
const article = props.article || {}
|
||||
const [authorResults, setAuthorResults] = useState([])
|
||||
const [authorQuery, setAuthorQuery] = useState(article.author?.title || article.author?.subtitle?.replace(/^@/, '') || '')
|
||||
const [selectedAuthor, setSelectedAuthor] = useState(article.author || props.defaultAuthor || null)
|
||||
const [relationResults, setRelationResults] = useState({})
|
||||
|
||||
const form = useForm({
|
||||
title: article.title || '',
|
||||
slug: article.slug || '',
|
||||
excerpt: article.excerpt || '',
|
||||
content: article.content || '',
|
||||
cover_image: article.cover_image || '',
|
||||
type: article.type || (props.typeOptions?.[0]?.value || 'announcement'),
|
||||
category_id: article.category_id || '',
|
||||
author_id: article.author_id || props.defaultAuthor?.id || '',
|
||||
editorial_status: article.editorial_status || 'draft',
|
||||
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
|
||||
is_featured: Boolean(article.is_featured),
|
||||
is_pinned: Boolean(article.is_pinned),
|
||||
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
|
||||
meta_title: article.meta_title || '',
|
||||
meta_description: article.meta_description || '',
|
||||
meta_keywords: article.meta_keywords || '',
|
||||
canonical_url: article.canonical_url || '',
|
||||
og_title: article.og_title || '',
|
||||
og_description: article.og_description || '',
|
||||
og_image: article.og_image || '',
|
||||
relations: Array.isArray(article.relations) ? article.relations.map((relation) => ({
|
||||
entity_type: relation.entity_type || 'group',
|
||||
entity_id: relation.entity_id || '',
|
||||
context_label: relation.context_label || '',
|
||||
preview: relation.preview || null,
|
||||
query: relation.preview?.title || '',
|
||||
})) : [],
|
||||
})
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (props.updateUrl) {
|
||||
form.patch(props.updateUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(props.storeUrl)
|
||||
}
|
||||
|
||||
const searchEntities = async (type, query) => {
|
||||
const url = new URL(props.entitySearchUrl, window.location.origin)
|
||||
url.searchParams.set('type', type)
|
||||
url.searchParams.set('q', query)
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
return Array.isArray(payload.items) ? payload.items : []
|
||||
}
|
||||
|
||||
const runAuthorSearch = async () => {
|
||||
const items = await searchEntities('user', authorQuery)
|
||||
setAuthorResults(items)
|
||||
}
|
||||
|
||||
const addRelation = () => {
|
||||
form.setData('relations', [
|
||||
...form.data.relations,
|
||||
{
|
||||
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
|
||||
entity_id: '',
|
||||
context_label: '',
|
||||
preview: null,
|
||||
query: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const updateRelation = (index, nextRelation) => {
|
||||
form.setData('relations', form.data.relations.map((relation, relationIndex) => (relationIndex === index ? nextRelation : relation)))
|
||||
}
|
||||
|
||||
const removeRelation = (index) => {
|
||||
form.setData('relations', form.data.relations.filter((_, relationIndex) => relationIndex !== index))
|
||||
setRelationResults((current) => {
|
||||
const next = { ...current }
|
||||
delete next[index]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const runRelationSearch = async (index) => {
|
||||
const relation = form.data.relations[index]
|
||||
if (!relation) return
|
||||
const items = await searchEntities(relation.entity_type, relation.query || '')
|
||||
setRelationResults((current) => ({ ...current, [index]: items }))
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
|
||||
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Cover image URL or path</span>
|
||||
<input value={form.data.cover_image} onChange={(event) => form.setData('cover_image', event.target.value)} placeholder="https://... or storage path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</span>
|
||||
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Body</span>
|
||||
<textarea value={form.data.content} onChange={(event) => form.setData('content', event.target.value)} rows={18} placeholder="Write in Markdown. Existing legacy HTML is still supported on render." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none" />
|
||||
</label>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Related entities</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Attach Groups, artworks, collections, releases, projects, challenges, events, and profiles.</p>
|
||||
</div>
|
||||
<button type="button" onClick={addRelation} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
|
||||
<RelationCard
|
||||
key={`${relation.entity_type}-${index}`}
|
||||
relation={relation}
|
||||
index={index}
|
||||
onChange={updateRelation}
|
||||
onRemove={removeRelation}
|
||||
onSearch={runRelationSearch}
|
||||
results={relationResults[index] || []}
|
||||
relationTypeOptions={Array.isArray(props.relationTypeOptions) ? props.relationTypeOptions : []}
|
||||
/>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No related entities attached yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">Publishing</h2>
|
||||
<div className="mt-5 grid gap-4">
|
||||
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center gap-2 rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-3 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15"><i className="fa-regular fa-eye" />Preview article</a> : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select value={form.data.type} onChange={(event) => form.setData('type', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
|
||||
<select value={form.data.category_id || ''} onChange={(event) => form.setData('category_id', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
<option value="">No category</option>
|
||||
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => <option key={option.id} value={option.id}>{option.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Workflow status</span>
|
||||
<select value={form.data.editorial_status} onChange={(event) => form.setData('editorial_status', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
||||
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
|
||||
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Author</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={authorQuery} onChange={(event) => setAuthorQuery(event.target.value)} placeholder="Search for an author" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="button" onClick={runAuthorSearch} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
{selectedAuthor ? (
|
||||
<div className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="font-semibold">Selected author: {selectedAuthor.title}</div>
|
||||
{selectedAuthor.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedAuthor.subtitle}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<SearchResultList items={authorResults} onSelect={(item) => {
|
||||
setSelectedAuthor(item)
|
||||
setAuthorQuery(item.title)
|
||||
form.setData('author_id', item.id)
|
||||
}} emptyLabel="Search to choose an author profile." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(Array.isArray(props.tagOptions) ? props.tagOptions : []).map((tag) => {
|
||||
const checked = form.data.tag_ids.includes(tag.id)
|
||||
|
||||
return (
|
||||
<label key={tag.id} className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
if (event.target.checked) {
|
||||
form.setData('tag_ids', [...form.data.tag_ids, tag.id])
|
||||
return
|
||||
}
|
||||
|
||||
form.setData('tag_ids', form.data.tag_ids.filter((tagId) => tagId !== tag.id))
|
||||
}}
|
||||
/>
|
||||
<span>{tag.name}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} />
|
||||
Feature on newsroom surfaces
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white">
|
||||
<input type="checkbox" checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} />
|
||||
Pin to the top of the newsroom
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<h2 className="text-xl font-semibold text-white">SEO & social</h2>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta title</span>
|
||||
<input value={form.data.meta_title} onChange={(event) => form.setData('meta_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta description</span>
|
||||
<textarea value={form.data.meta_description} onChange={(event) => form.setData('meta_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Meta keywords</span>
|
||||
<input value={form.data.meta_keywords} onChange={(event) => form.setData('meta_keywords', event.target.value)} placeholder="creator-story, release, tutorial" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Canonical URL</span>
|
||||
<input value={form.data.canonical_url} onChange={(event) => form.setData('canonical_url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG title</span>
|
||||
<input value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG image</span>
|
||||
<input value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG description</span>
|
||||
<textarea value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} rows={3} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-3">
|
||||
<button type="submit" disabled={form.processing} className="w-full rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60">Save article</button>
|
||||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100">Publish now</button> : null}
|
||||
{props.featureUrl ? <button type="button" onClick={() => router.post(props.featureUrl)} className="w-full rounded-full border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white">Toggle featured</button> : null}
|
||||
{props.pinUrl ? <button type="button" onClick={() => router.post(props.pinUrl)} className="w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">Toggle pinned</button> : null}
|
||||
{props.archiveUrl ? <button type="button" onClick={() => router.post(props.archiveUrl)} className="w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Archive article</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
164
resources/js/Pages/Studio/StudioNewsIndex.jsx
Normal file
164
resources/js/Pages/Studio/StudioNewsIndex.jsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Draft'
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'Draft'
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
|
||||
case 'scheduled':
|
||||
return 'border-sky-300/20 bg-sky-400/10 text-sky-100'
|
||||
case 'in_review':
|
||||
return 'border-amber-300/20 bg-amber-400/10 text-amber-100'
|
||||
case 'archived':
|
||||
return 'border-white/10 bg-white/[0.05] text-slate-300'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.05] text-slate-300'
|
||||
}
|
||||
}
|
||||
|
||||
export default function StudioNewsIndex() {
|
||||
const { props } = usePage()
|
||||
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
|
||||
const filters = props.listing?.filters || {}
|
||||
const meta = props.listing?.meta || {}
|
||||
|
||||
const updateFilter = (next) => {
|
||||
router.get('/studio/news', {
|
||||
...filters,
|
||||
...next,
|
||||
page: 1,
|
||||
}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.9))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75">Editorial surface</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Run a first-party newsroom for launches, tutorials, and community stories.</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">Pinned stories drive the hero, featured pieces strengthen discovery, and related entity links keep News wired into Groups, releases, collections, and profiles.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a href={props.createUrl} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-plus" />
|
||||
New article
|
||||
</a>
|
||||
<a href={props.categoriesUrl} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-tags" />
|
||||
Taxonomies
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px_220px_220px_auto] lg:items-center">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<input
|
||||
defaultValue={filters.q || ''}
|
||||
placeholder="Search titles, excerpts, and metadata"
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
updateFilter({
|
||||
q: event.currentTarget.value || '',
|
||||
status: filters.status || '',
|
||||
type: filters.type || '',
|
||||
category_id: filters.category_id || '',
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(event) => updateFilter({ status: event.target.value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<select
|
||||
value={filters.type || ''}
|
||||
onChange={(event) => updateFilter({ type: event.target.value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
|
||||
<select
|
||||
value={filters.category_id || ''}
|
||||
onChange={(event) => updateFilter({ category_id: event.target.value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<article key={item.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.18)]">
|
||||
<div className="aspect-[16/9] bg-slate-950/60">
|
||||
{item.cover_url ? <img src={item.cover_url} alt={item.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-newspaper text-3xl" /></div>}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-white/70">{item.type_label}</span>
|
||||
<span className={`rounded-full border px-2.5 py-1 ${statusTone(item.editorial_status)}`}>{item.editorial_status.replaceAll('_', ' ')}</span>
|
||||
{item.is_pinned ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-amber-100">Pinned</span> : null}
|
||||
{item.is_featured ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-100">Featured</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 text-xl font-semibold text-white">{item.title}</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-sm text-slate-400">
|
||||
{item.category_name ? <span>{item.category_name}</span> : null}
|
||||
<span>{item.author_name}</span>
|
||||
<span>{formatDate(item.published_at)}</span>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<a href={item.edit_url} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Edit</a>
|
||||
<a href={item.editorial_status === 'published' ? item.public_url : item.preview_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">{item.editorial_status === 'published' ? 'View' : 'Preview'}</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No News articles match the current filters.</div>}
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
115
resources/js/Pages/Studio/StudioNewsTaxonomies.jsx
Normal file
115
resources/js/Pages/Studio/StudioNewsTaxonomies.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
|
||||
function replacePattern(pattern, token, value) {
|
||||
return String(pattern || '').replace(token, String(value))
|
||||
}
|
||||
|
||||
export default function StudioNewsTaxonomies() {
|
||||
const { props } = usePage()
|
||||
const [categories, setCategories] = useState(Array.isArray(props.categories) ? props.categories : [])
|
||||
const [tags, setTags] = useState(Array.isArray(props.tags) ? props.tags : [])
|
||||
const categoryForm = useForm({ name: '', slug: '', description: '', position: 0, is_active: true })
|
||||
const tagForm = useForm({ name: '', slug: '' })
|
||||
|
||||
const updateCategory = (index, field, value) => {
|
||||
setCategories((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)))
|
||||
}
|
||||
|
||||
const updateTag = (index, field, value) => {
|
||||
setTags((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)))
|
||||
}
|
||||
|
||||
const saveCategory = (category) => {
|
||||
router.patch(replacePattern(props.updateCategoryUrlPattern, '__CATEGORY__', category.id), category, { preserveScroll: true })
|
||||
}
|
||||
|
||||
const saveTag = (tag) => {
|
||||
router.patch(replacePattern(props.updateTagUrlPattern, '__TAG__', tag.id), tag, { preserveScroll: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<StudioLayout title={props.title} subtitle={props.description}>
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-wrap gap-3 text-sm font-semibold">
|
||||
<a href="/studio/news/categories" className={`rounded-full px-4 py-2 ${props.activeTab === 'categories' ? 'border border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border border-white/10 bg-white/[0.04] text-white'}`}>Categories</a>
|
||||
<a href="/studio/news/tags" className={`rounded-full px-4 py-2 ${props.activeTab === 'tags' ? 'border border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border border-white/10 bg-white/[0.04] text-white'}`}>Tags</a>
|
||||
<a href="/studio/news" className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white">Back to newsroom</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Categories</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Stable editorial buckets for the newsroom.</p>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">{categories.length} total</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => { event.preventDefault(); categoryForm.post(props.storeCategoryUrl) }} className="mt-5 grid gap-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input value={categoryForm.data.name} onChange={(event) => categoryForm.setData('name', event.target.value)} placeholder="Category name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={categoryForm.data.slug} onChange={(event) => categoryForm.setData('slug', event.target.value)} placeholder="optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<textarea value={categoryForm.data.description} onChange={(event) => categoryForm.setData('description', event.target.value)} rows={3} placeholder="Description" className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input type="number" value={categoryForm.data.position} onChange={(event) => categoryForm.setData('position', event.target.value)} min="0" className="w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<label className="flex items-center gap-2 text-sm text-white"><input type="checkbox" checked={categoryForm.data.is_active} onChange={(event) => categoryForm.setData('is_active', event.target.checked)} /> Active</label>
|
||||
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Create category</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
{categories.map((category, index) => (
|
||||
<div key={category.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input value={category.name} onChange={(event) => updateCategory(index, 'name', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={category.slug} onChange={(event) => updateCategory(index, 'slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</div>
|
||||
<textarea value={category.description || ''} onChange={(event) => updateCategory(index, 'description', event.target.value)} rows={2} className="mt-3 w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<input type="number" value={category.position || 0} min="0" onChange={(event) => updateCategory(index, 'position', event.target.value)} className="w-24 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(category.is_active)} onChange={(event) => updateCategory(index, 'is_active', event.target.checked)} /> Active</label>
|
||||
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(category.published_count || 0).toLocaleString()} published</span>
|
||||
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Tags</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Flexible labels for search, discovery, and internal linking.</p>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">{tags.length} total</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => { event.preventDefault(); tagForm.post(props.storeTagUrl) }} className="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center">
|
||||
<input value={tagForm.data.name} onChange={(event) => tagForm.setData('name', event.target.value)} placeholder="Tag name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={tagForm.data.slug} onChange={(event) => tagForm.setData('slug', event.target.value)} placeholder="optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100">Create tag</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={tag.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] md:items-center">
|
||||
<input value={tag.name} onChange={(event) => updateTag(index, 'name', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<input value={tag.slug} onChange={(event) => updateTag(index, 'slug', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(tag.published_count || 0).toLocaleString()} published</span>
|
||||
<button type="button" onClick={() => saveTag(tag)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import StudioGroupMembers from '../StudioGroupMembers'
|
||||
|
||||
const { routerMock } = vi.hoisted(() => ({
|
||||
routerMock: {
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
let pageMock = { props: {} }
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
usePage: () => pageMock,
|
||||
router: routerMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../../Layouts/StudioLayout', () => ({
|
||||
default: ({ children }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('StudioGroupMembers permissions', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows management controls for owners and admins', () => {
|
||||
pageMock = {
|
||||
props: {
|
||||
title: 'Members',
|
||||
description: 'Manage members',
|
||||
canManageMembers: true,
|
||||
endpoints: {
|
||||
invite: '/studio/groups/warp/members',
|
||||
invitations: '/studio/groups/warp/invitations',
|
||||
updatePattern: '/studio/groups/warp/members/__MEMBER__',
|
||||
transferPattern: '/studio/groups/warp/members/__MEMBER__/transfer',
|
||||
deletePattern: '/studio/groups/warp/members/__MEMBER__',
|
||||
},
|
||||
members: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'editor',
|
||||
role_label: 'editor',
|
||||
status: 'active',
|
||||
can_transfer: true,
|
||||
can_revoke: true,
|
||||
user: { name: 'Editor User', username: 'editor-user', avatar_url: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<StudioGroupMembers />)
|
||||
|
||||
expect(screen.getByText('Invite member')).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: /manage invitations/i })).not.toBeNull()
|
||||
expect(screen.getByPlaceholderText(/name, username, or role/i)).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: /transfer/i })).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: /remove/i })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hides management controls for non-managing members', () => {
|
||||
pageMock = {
|
||||
props: {
|
||||
title: 'Members',
|
||||
description: 'Manage members',
|
||||
canManageMembers: false,
|
||||
endpoints: null,
|
||||
members: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'editor',
|
||||
role_label: 'editor',
|
||||
status: 'active',
|
||||
can_transfer: false,
|
||||
can_revoke: false,
|
||||
user: { name: 'Editor User', username: 'editor-user', avatar_url: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<StudioGroupMembers />)
|
||||
|
||||
expect(screen.queryByText('Invite member')).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /manage invitations/i })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /transfer/i })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /remove/i })).toBeNull()
|
||||
expect(screen.getByText('editor')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user