Files
SkinbaseNova/.deploy/artwork-evolution-release/resources/js/Pages/Studio/StudioGroupInvitations.jsx
2026-04-18 17:02:56 +02:00

129 lines
9.0 KiB
JavaScript

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>
)
}