233 lines
13 KiB
JavaScript
233 lines
13 KiB
JavaScript
import React, { useMemo, useRef, useState } from 'react'
|
|
import { router, usePage } from '@inertiajs/react'
|
|
import StudioLayout from '../../Layouts/StudioLayout'
|
|
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
|
|
import DateTimePicker from '../../components/ui/DateTimePicker'
|
|
import NovaSelect from '../../components/ui/NovaSelect'
|
|
|
|
function slugifyGroupValue(value) {
|
|
return String(value || '')
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 90)
|
|
}
|
|
|
|
function resolveMediaPreviewUrl(path, filesCdnUrl) {
|
|
const trimmed = String(path || '').trim()
|
|
|
|
if (!trimmed) {
|
|
return ''
|
|
}
|
|
|
|
if (trimmed.startsWith('blob:') || trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
return trimmed
|
|
}
|
|
|
|
return `${String(filesCdnUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\/+/, '')}`
|
|
}
|
|
|
|
export default function StudioGroupCreate() {
|
|
const { props } = usePage()
|
|
const filesCdnUrl = props?.cdn?.files_url || ''
|
|
const avatarInputRef = useRef(null)
|
|
const bannerInputRef = useRef(null)
|
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
|
|
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 resolvedAvatarPreview = useMemo(() => avatarPreview || resolveMediaPreviewUrl(form.avatar_path, filesCdnUrl), [avatarPreview, form.avatar_path, filesCdnUrl])
|
|
const resolvedBannerPreview = useMemo(() => bannerPreview || resolveMediaPreviewUrl(form.banner_path, filesCdnUrl), [bannerPreview, form.banner_path, filesCdnUrl])
|
|
|
|
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 = ''
|
|
}
|
|
}
|
|
|
|
const handleNameChange = (event) => {
|
|
const nextName = event.target.value
|
|
|
|
setForm((current) => ({
|
|
...current,
|
|
name: nextName,
|
|
slug: slugManuallyEdited ? current.slug : slugifyGroupValue(nextName),
|
|
}))
|
|
}
|
|
|
|
const handleSlugChange = (event) => {
|
|
const nextSlug = slugifyGroupValue(event.target.value)
|
|
|
|
setSlugManuallyEdited(nextSlug !== '')
|
|
setForm((current) => ({ ...current, slug: nextSlug }))
|
|
}
|
|
|
|
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={handleNameChange} 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={handleSlugChange} 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>
|
|
<div className="grid gap-2 text-sm text-slate-200">
|
|
<span>Founded date</span>
|
|
<DateTimePicker value={form.founded_at} onChange={(nextValue) => setForm((current) => ({ ...current, founded_at: nextValue }))} mode="date" placeholder="Pick the founding date" clearable className="bg-black/20" />
|
|
</div>
|
|
</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]">
|
|
{resolvedAvatarPreview ? <img src={resolvedAvatarPreview} 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]">
|
|
{resolvedBannerPreview ? <img src={resolvedBannerPreview} 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>
|
|
<div className="grid gap-2 text-sm text-slate-200">
|
|
<span>Visibility</span>
|
|
<NovaSelect value={form.visibility} onChange={(val) => setForm((current) => ({ ...current, visibility: val }))} options={props.visibilityOptions || []} searchable={false} />
|
|
</div>
|
|
<div className="grid gap-2 text-sm text-slate-200">
|
|
<span>Membership policy</span>
|
|
<NovaSelect value={form.membership_policy} onChange={(val) => setForm((current) => ({ ...current, membership_policy: val }))} options={props.membershipPolicyOptions || []} searchable={false} />
|
|
</div>
|
|
<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>
|
|
)
|
|
} |