Commit workspace changes
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user