Files
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

182 lines
7.9 KiB
TypeScript

import { type BreadcrumbItem, type SharedData } from '@/types';
import { Head, router, useForm, usePage } from '@inertiajs/react';
import { X } from 'lucide-react';
import { FormEventHandler, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { GroupForm } from '@/components/group-form';
import Heading from '@/components/heading';
import HeadingSmall from '@/components/heading-small';
import InputError from '@/components/input-error';
import { SaveButton } from '@/components/save-button';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
interface Person {
id: number;
name: string;
email: string;
}
interface GroupsEditProps {
group: {
id: number;
name: string;
slug: string;
description: string | null;
public: boolean;
};
members: Person[];
available_clients: Person[];
}
interface GroupFormData {
[key: string]: string | boolean;
name: string;
slug: string;
description: string;
public: boolean;
}
type Tab = 'general' | 'members';
export default function GroupsEdit({ group, members, available_clients }: GroupsEditProps) {
const { t } = useTranslation();
const { auth } = usePage<SharedData>().props;
const pageErrors = usePage().props.errors as Record<string, string>;
const [tab, setTab] = useState<Tab>('general');
const [selectedClient, setSelectedClient] = useState('');
const breadcrumbs: BreadcrumbItem[] = [
{ title: t('Groups'), href: '/groups' },
{ title: group.name, href: `/groups/${group.id}` },
];
const { data, setData, patch, processing, errors, recentlySuccessful } = useForm<GroupFormData>({
name: group.name,
slug: group.slug,
description: group.description ?? '',
public: group.public,
});
const deleteForm = useForm({});
const submit: FormEventHandler = (e) => {
e.preventDefault();
patch(route('groups.update', group.id));
};
const addMember = () => {
if (selectedClient !== '') {
router.post(
route('groups.members.store', group.id),
{ user_id: Number(selectedClient) },
{ preserveScroll: true, onSuccess: () => setSelectedClient('') },
);
}
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={group.name} />
<div className="px-4 py-6">
<div className="flex items-start justify-between">
<Heading title={group.name} description={t(':count members', { count: members.length })} />
{auth.permissions.includes('delete_groups') && (
<ConfirmDialog
trigger={
<Button variant="destructive" disabled={deleteForm.processing}>
{t('Delete group')}
</Button>
}
title={t('Delete group?')}
description={t('The group ":name" will be deleted. Its clients keep their accounts.', { name: group.name })}
confirmLabel={t('Delete group')}
onConfirm={() => deleteForm.delete(route('groups.destroy', group.id))}
/>
)}
</div>
<nav className="mt-6 flex gap-1 border-b">
{(['general', 'members'] as Tab[]).map((tabKey) => (
<button
key={tabKey}
onClick={() => setTab(tabKey)}
className={`border-b-2 px-3 py-2 text-sm ${tab === tabKey ? 'border-primary text-foreground font-medium' : 'text-muted-foreground border-transparent'}`}
>
{tabKey === 'general' ? t('General') : t('Members')}
</button>
))}
</nav>
<div className="mt-6">
{tab === 'general' ? (
<form onSubmit={submit} className="max-w-md space-y-6">
<GroupForm
name={data.name}
slug={data.slug}
description={data.description}
isPublic={data.public}
onChange={(field, value) => setData(field, value)}
errors={errors}
/>
<SaveButton processing={processing} recentlySuccessful={recentlySuccessful} />
</form>
) : (
<div className="max-w-xl space-y-4">
<HeadingSmall title={t('Members')} description={t('The clients this group shares files with')} />
<div className="flex gap-2">
<Select value={selectedClient} onValueChange={setSelectedClient}>
<SelectTrigger className="w-72">
<SelectValue placeholder={t('Select a client to add')} />
</SelectTrigger>
<SelectContent>
{available_clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{client.name} {client.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" onClick={addMember} disabled={selectedClient === ''}>
{t('Add')}
</Button>
</div>
<InputError message={pageErrors.user_id} />
<div className="rounded-lg border">
{members.length === 0 && (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">{t('No members yet.')}</p>
)}
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between border-b px-4 py-2 last:border-0">
<div>
<p className="text-sm font-medium">{member.name}</p>
<p className="text-muted-foreground text-xs">{member.email}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() =>
router.delete(route('groups.members.destroy', [group.id, member.id]), { preserveScroll: true })
}
>
<X className="size-4" />
<span className="sr-only">{t('Remove')}</span>
</Button>
</div>
))}
</div>
</div>
)}
</div>
</div>
</AppLayout>
);
}