126 lines
8.9 KiB
JavaScript
126 lines
8.9 KiB
JavaScript
import React, { useMemo, useState } from 'react'
|
|
import { Link, router, usePage } from '@inertiajs/react'
|
|
import StudioLayout from '../../Layouts/StudioLayout'
|
|
import NovaSelect from '../../components/ui/NovaSelect'
|
|
|
|
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" />
|
|
<NovaSelect value={invite.role} onChange={(val) => setInvite((current) => ({ ...current, role: val }))} searchable={false} options={[{ value: 'contributor', label: 'Contributor' }, { value: 'editor', label: 'Editor' }, { value: 'admin', label: 'Admin' }]} />
|
|
<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>
|
|
)
|
|
} |