mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 04:15:35 +00:00
feat: team design changes
This commit is contained in:
1 parent
d21f4758be
commit
d3dc976d3c
9 files changed
+447
-116
No files matched your search
@@ -62,7 +62,12 @@ class TeamsRepository:
|
||||
"""
|
||||
SELECT t.*,
|
||||
CASE WHEN bool_or(m.role = 'team_admin') THEN 'team_admin'
|
||||
ELSE 'team_member' END AS member_role
|
||||
ELSE 'team_member' END AS member_role,
|
||||
(SELECT count(DISTINCT mm.user_id) FROM team_members mm
|
||||
WHERE mm.team_id = t.id) AS member_count,
|
||||
(SELECT count(DISTINCT g.resource_id)
|
||||
FROM team_resource_grants g
|
||||
WHERE g.team_id = t.id) AS shared_count
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = :user_id
|
||||
|
||||
@@ -175,6 +175,42 @@ export default function AgentCard({
|
||||
iconHeight: 13,
|
||||
},
|
||||
],
|
||||
// Agents shared with the user via a team. They don't own it, so only
|
||||
// non-destructive, non-owner actions are offered: open the config
|
||||
// (editors can save, viewers see it read-only) and pin for quick access.
|
||||
// Logs / Export / Share / Move-to-folder / Delete stay owner-only.
|
||||
team: [
|
||||
{
|
||||
icon: Edit,
|
||||
label: 'Edit',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
if (agent.agent_type === 'workflow') {
|
||||
navigate(`/agents/workflow/edit/${agent.id}`);
|
||||
} else {
|
||||
navigate(`/agents/edit/${agent.id}`);
|
||||
}
|
||||
},
|
||||
variant: 'default',
|
||||
iconWidth: 14,
|
||||
iconHeight: 14,
|
||||
},
|
||||
...(agent.status === 'published'
|
||||
? [
|
||||
{
|
||||
icon: agent.pinned ? UnPin : Pin,
|
||||
label: agent.pinned ? 'Unpin' : 'Pin agent',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
togglePin();
|
||||
},
|
||||
variant: 'default' as const,
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
shared: [
|
||||
{
|
||||
icon: Link,
|
||||
@@ -214,7 +250,9 @@ export default function AgentCard({
|
||||
const menuOptions = menuOptionsConfig[section] || [];
|
||||
|
||||
const handleClick = () => {
|
||||
if (section === 'user') {
|
||||
// Team-shared agents open/run exactly like the user's own published
|
||||
// agents (the run + GetAgent routes authorize team grantees server-side).
|
||||
if (section === 'user' || section === 'team') {
|
||||
if (agent.status === 'published') {
|
||||
dispatch(setSelectedAgent(agent));
|
||||
navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new');
|
||||
@@ -378,6 +416,16 @@ export default function AgentCard({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Team access badge — pinned to the top row, left of the ⋯ menu
|
||||
(right-11 clears the 19px trigger at right-4) so the two align. */}
|
||||
{agent.ownership === 'team' && (
|
||||
<span className="bg-muted dark:bg-accent text-muted-foreground absolute top-4 right-11 z-10 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
|
||||
<Users size={11} strokeWidth={2} aria-hidden="true" />
|
||||
{agent.team_access === 'editor'
|
||||
? t('agents.teamBadge.editor')
|
||||
: t('agents.teamBadge.viewer')}
|
||||
</span>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-1 px-1">
|
||||
<Avatar
|
||||
|
||||
@@ -32,6 +32,7 @@ const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'agents.filters.all' },
|
||||
{ id: 'template', labelKey: 'agents.filters.byDocsGPT' },
|
||||
{ id: 'user', labelKey: 'agents.filters.byMe' },
|
||||
{ id: 'team', labelKey: 'agents.filters.team' },
|
||||
{ id: 'shared', labelKey: 'agents.filters.shared' },
|
||||
];
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
setTemplateAgents,
|
||||
} from '../preferences/preferenceSlice';
|
||||
|
||||
export type AgentSectionId = 'template' | 'user' | 'shared';
|
||||
export type AgentSectionId = 'template' | 'user' | 'team' | 'shared';
|
||||
|
||||
export const agentSectionsConfig = [
|
||||
{
|
||||
id: 'template' as const,
|
||||
title: 'By DocsGPT',
|
||||
title: 'Templates',
|
||||
description: 'Agents provided by DocsGPT',
|
||||
showNewAgentButton: false,
|
||||
emptyStateDescription: 'No template agents found.',
|
||||
@@ -23,7 +23,7 @@ export const agentSectionsConfig = [
|
||||
},
|
||||
{
|
||||
id: 'user' as const,
|
||||
title: 'By me',
|
||||
title: 'My agents',
|
||||
description: 'Agents created or published by you',
|
||||
showNewAgentButton: true,
|
||||
emptyStateDescription: 'You don’t have any created agents yet.',
|
||||
@@ -32,11 +32,25 @@ export const agentSectionsConfig = [
|
||||
updateAction: setAgents,
|
||||
},
|
||||
{
|
||||
id: 'shared' as const,
|
||||
title: 'Shared with me',
|
||||
description: 'Agents imported by using a public link',
|
||||
// Team-shared agents are split out of the 'user' payload client-side
|
||||
// (they arrive in /api/get_agents tagged ownership:'team'), so this
|
||||
// section reuses the same selector/action — the hook partitions by
|
||||
// ownership for display.
|
||||
id: 'team' as const,
|
||||
title: 'Team',
|
||||
description: 'Agents your teammates shared with you',
|
||||
showNewAgentButton: false,
|
||||
emptyStateDescription: 'No shared agents found.',
|
||||
emptyStateDescription: 'No agents have been shared with your team yet.',
|
||||
fetchAgents: (token: string | null) => userService.getAgents(token),
|
||||
selectData: selectAgents,
|
||||
updateAction: setAgents,
|
||||
},
|
||||
{
|
||||
id: 'shared' as const,
|
||||
title: 'Discovered',
|
||||
description: "Public agents you've opened via a link",
|
||||
showNewAgentButton: false,
|
||||
emptyStateDescription: "You haven't opened any public agents yet.",
|
||||
fetchAgents: (token: string | null) => userService.getSharedAgents(token),
|
||||
selectData: selectSharedAgents,
|
||||
updateAction: setSharedAgents,
|
||||
|
||||
@@ -45,9 +45,28 @@ export function useAgentSearch(): UseAgentSearchResult {
|
||||
const [activeFilter, setActiveFilter] = useState<AgentFilterTab>('all');
|
||||
|
||||
const templateAgents = useSelector(selectTemplateAgents);
|
||||
const userAgents = useSelector(selectAgents);
|
||||
const allUserAgents = useSelector(selectAgents);
|
||||
const sharedAgents = useSelector(selectSharedAgents);
|
||||
|
||||
// /api/get_agents returns both the caller's own agents and agents shared
|
||||
// with their teams (tagged ownership:'team'). Split them so "By me" shows
|
||||
// only owned agents and "Shared with my team" gets its own section.
|
||||
// Preserve the null (still-loading) state for both buckets.
|
||||
const userAgents = useMemo(
|
||||
() =>
|
||||
allUserAgents === null
|
||||
? null
|
||||
: allUserAgents.filter((a) => a.ownership !== 'team'),
|
||||
[allUserAgents],
|
||||
);
|
||||
const teamAgents = useMemo(
|
||||
() =>
|
||||
allUserAgents === null
|
||||
? null
|
||||
: allUserAgents.filter((a) => a.ownership === 'team'),
|
||||
[allUserAgents],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback((query: string) => {
|
||||
setSearchQuery(query);
|
||||
}, []);
|
||||
@@ -60,24 +79,27 @@ export function useAgentSearch(): UseAgentSearchResult {
|
||||
(): Record<AgentSectionId, boolean> => ({
|
||||
template: templateAgents !== null,
|
||||
user: userAgents !== null,
|
||||
team: teamAgents !== null,
|
||||
shared: sharedAgents !== null,
|
||||
}),
|
||||
[templateAgents, userAgents, sharedAgents],
|
||||
[templateAgents, userAgents, teamAgents, sharedAgents],
|
||||
);
|
||||
|
||||
const totalAgentsBySection = useMemo(
|
||||
(): Record<AgentSectionId, number> => ({
|
||||
template: templateAgents?.length ?? 0,
|
||||
user: userAgents?.length ?? 0,
|
||||
team: teamAgents?.length ?? 0,
|
||||
shared: sharedAgents?.length ?? 0,
|
||||
}),
|
||||
[templateAgents, userAgents, sharedAgents],
|
||||
[templateAgents, userAgents, teamAgents, sharedAgents],
|
||||
);
|
||||
|
||||
const filteredAgentsBySection = useMemo((): AgentsBySection => {
|
||||
const filtered = {
|
||||
template: filterAgentsByQuery(templateAgents, searchQuery),
|
||||
user: filterAgentsByQuery(userAgents, searchQuery),
|
||||
team: filterAgentsByQuery(teamAgents, searchQuery),
|
||||
shared: filterAgentsByQuery(sharedAgents, searchQuery),
|
||||
};
|
||||
|
||||
@@ -88,14 +110,23 @@ export function useAgentSearch(): UseAgentSearchResult {
|
||||
return {
|
||||
template: activeFilter === 'template' ? filtered.template : [],
|
||||
user: activeFilter === 'user' ? filtered.user : [],
|
||||
team: activeFilter === 'team' ? filtered.team : [],
|
||||
shared: activeFilter === 'shared' ? filtered.shared : [],
|
||||
};
|
||||
}, [templateAgents, userAgents, sharedAgents, searchQuery, activeFilter]);
|
||||
}, [
|
||||
templateAgents,
|
||||
userAgents,
|
||||
teamAgents,
|
||||
sharedAgents,
|
||||
searchQuery,
|
||||
activeFilter,
|
||||
]);
|
||||
|
||||
const hasAnyAgents = useMemo(() => {
|
||||
return (
|
||||
totalAgentsBySection.template > 0 ||
|
||||
totalAgentsBySection.user > 0 ||
|
||||
totalAgentsBySection.team > 0 ||
|
||||
totalAgentsBySection.shared > 0
|
||||
);
|
||||
}, [totalAgentsBySection]);
|
||||
@@ -104,6 +135,7 @@ export function useAgentSearch(): UseAgentSearchResult {
|
||||
return (
|
||||
filteredAgentsBySection.template.length > 0 ||
|
||||
filteredAgentsBySection.user.length > 0 ||
|
||||
filteredAgentsBySection.team.length > 0 ||
|
||||
filteredAgentsBySection.shared.length > 0
|
||||
);
|
||||
}, [filteredAgentsBySection]);
|
||||
|
||||
@@ -25,6 +25,9 @@ export function useAgentsFetch(): UseAgentsFetchResult {
|
||||
const [isLoading, setIsLoading] = useState<Record<AgentSectionId, boolean>>({
|
||||
template: true,
|
||||
user: true,
|
||||
// 'team' agents are split out of the same /api/get_agents payload, so
|
||||
// their loading state tracks the user fetch.
|
||||
team: true,
|
||||
shared: true,
|
||||
});
|
||||
|
||||
@@ -50,7 +53,7 @@ export function useAgentsFetch(): UseAgentsFetchResult {
|
||||
} catch (error) {
|
||||
dispatch(setAgents([]));
|
||||
} finally {
|
||||
setIsLoading((prev) => ({ ...prev, user: false }));
|
||||
setIsLoading((prev) => ({ ...prev, user: false, team: false }));
|
||||
}
|
||||
}, [token, dispatch]);
|
||||
|
||||
@@ -79,7 +82,7 @@ export function useAgentsFetch(): UseAgentsFetchResult {
|
||||
}, [token, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading({ template: true, user: true, shared: true });
|
||||
setIsLoading({ template: true, user: true, team: true, shared: true });
|
||||
Promise.all([
|
||||
fetchTemplateAgents(),
|
||||
fetchUserAgents(),
|
||||
@@ -89,7 +92,10 @@ export function useAgentsFetch(): UseAgentsFetchResult {
|
||||
}, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents, fetchFolders]);
|
||||
|
||||
const isAllLoaded =
|
||||
!isLoading.template && !isLoading.user && !isLoading.shared;
|
||||
!isLoading.template &&
|
||||
!isLoading.user &&
|
||||
!isLoading.team &&
|
||||
!isLoading.shared;
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
|
||||
@@ -224,10 +224,18 @@
|
||||
"backToTeams": "Back to teams",
|
||||
"createTeam": "Create a team",
|
||||
"createTeamDescription": "Give your team a name. You can add members and share resources once it's created.",
|
||||
"editTeam": "Edit",
|
||||
"teamNamePlaceholder": "Team name",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "What's this team for? (optional)",
|
||||
"create": "Create",
|
||||
"save": "Save",
|
||||
"createTeamError": "Could not create team.",
|
||||
"noTeams": "No teams yet.",
|
||||
"noDescription": "No description",
|
||||
"memberCountOne": "{{count}} member",
|
||||
"memberCountOther": "{{count}} members",
|
||||
"sharedCount": "{{count}} shared",
|
||||
"loadError": "Failed to load teams.",
|
||||
"roleAdmin": "admin",
|
||||
"roleMember": "member",
|
||||
@@ -241,6 +249,10 @@
|
||||
"deleteTeam": "Delete team",
|
||||
"deleteTeamConfirmation": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
|
||||
"members": "Members",
|
||||
"addMember": "Add member",
|
||||
"addMemberTitle": "Add a member",
|
||||
"addMemberDescription": "Invite someone by email. They must have signed in at least once.",
|
||||
"teamActions": "Team actions",
|
||||
"memberEmailLabel": "Member email",
|
||||
"memberEmailPlaceholder": "name@example.com",
|
||||
"memberRoleLabel": "Role",
|
||||
@@ -891,25 +903,35 @@
|
||||
"tryDifferentSearch": "Try a different search term",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"byDocsGPT": "By DocsGPT",
|
||||
"byMe": "By Me",
|
||||
"shared": "Shared With Me"
|
||||
"byDocsGPT": "Templates",
|
||||
"byMe": "My agents",
|
||||
"team": "Team",
|
||||
"shared": "Discovered"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "Editor",
|
||||
"viewer": "Viewer"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "By DocsGPT",
|
||||
"title": "Templates",
|
||||
"description": "Agents provided by DocsGPT",
|
||||
"emptyState": "No template agents found."
|
||||
},
|
||||
"user": {
|
||||
"title": "By me",
|
||||
"title": "My agents",
|
||||
"description": "Agents created or published by you",
|
||||
"emptyState": "You don't have any created agents yet."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"description": "Agents your teammates shared with you",
|
||||
"emptyState": "No agents have been shared with your team yet."
|
||||
},
|
||||
"shared": {
|
||||
"title": "Shared with me",
|
||||
"description": "Agents imported by using a public link",
|
||||
"emptyState": "No shared agents found."
|
||||
"title": "Discovered",
|
||||
"description": "Public agents you've opened via a link",
|
||||
"emptyState": "You haven't opened any public agents yet."
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
|
||||
+292
-92
@@ -4,8 +4,11 @@ import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
@@ -23,6 +26,12 @@ import NoFilesIcon from '../assets/no-files.svg';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../components/ui/dropdown-menu';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import {
|
||||
@@ -109,8 +118,14 @@ export default function Teams() {
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [newMemberEmail, setNewMemberEmail] = useState('');
|
||||
const [newMemberRole, setNewMemberRole] = useState<TeamRole>('team_member');
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
const [addMemberError, setAddMemberError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [deleteTeamModalState, setDeleteTeamModalState] =
|
||||
@@ -284,6 +299,42 @@ export default function Teams() {
|
||||
setCreateError(null);
|
||||
};
|
||||
|
||||
const openEditModal = () => {
|
||||
if (!selected) return;
|
||||
setEditName(selected.name);
|
||||
setEditDescription(selected.description ?? '');
|
||||
setEditError(null);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditOpen(false);
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!selected || !editName.trim()) return;
|
||||
const name = editName.trim();
|
||||
const description = editDescription.trim();
|
||||
try {
|
||||
const res = await teamsService.update(
|
||||
selected.id,
|
||||
{ name, description },
|
||||
token,
|
||||
);
|
||||
if (!res || res.success === false) {
|
||||
setEditError(t('settings.teams.updateFailed'));
|
||||
return;
|
||||
}
|
||||
// Reflect locally and refresh the list so the card/switcher update too.
|
||||
setSelected({ ...selected, name, description });
|
||||
dispatch(loadTeams({ token }));
|
||||
setEditOpen(false);
|
||||
} catch {
|
||||
setEditError(t('settings.teams.updateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Consume navigation intent from the team switcher: "Manage team" passes
|
||||
// openTeamId (open that team's detail directly); "Create team" passes
|
||||
// create:true (pop the create modal). Consumed once, then the history state
|
||||
@@ -321,9 +372,21 @@ export default function Teams() {
|
||||
}
|
||||
}, [teams]);
|
||||
|
||||
const openAddMemberModal = () => {
|
||||
setNewMemberEmail('');
|
||||
setNewMemberRole('team_member');
|
||||
setAddMemberError(null);
|
||||
setAddMemberOpen(true);
|
||||
};
|
||||
|
||||
const closeAddMemberModal = () => {
|
||||
setAddMemberOpen(false);
|
||||
setAddMemberError(null);
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selected || !newMemberEmail.trim()) return;
|
||||
setError(null);
|
||||
setAddMemberError(null);
|
||||
try {
|
||||
const res = await teamsService.addMember(
|
||||
selected.id,
|
||||
@@ -333,13 +396,15 @@ export default function Teams() {
|
||||
if (res?.success === false) {
|
||||
// The backend returns 404 with a message when the email maps to no
|
||||
// known user; surface its message (e.g. "they must sign in first").
|
||||
setError(res.message ?? t('settings.teams.memberNotFound'));
|
||||
setAddMemberError(res.message ?? t('settings.teams.memberNotFound'));
|
||||
return;
|
||||
}
|
||||
setNewMemberEmail('');
|
||||
setNewMemberRole('team_member');
|
||||
setAddMemberOpen(false);
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
setError(t('settings.teams.addMemberError'));
|
||||
setAddMemberError(t('settings.teams.addMemberError'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -419,7 +484,7 @@ export default function Teams() {
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
role === 'team_admin'
|
||||
? 'bg-primary/10 text-primary dark:bg-primary/20'
|
||||
? 'bg-muted-foreground/15 text-foreground'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
@@ -446,7 +511,7 @@ export default function Teams() {
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4 md:p-12">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground text-2xl font-bold">
|
||||
{t('settings.teams.label')}
|
||||
@@ -462,7 +527,7 @@ export default function Teams() {
|
||||
</div>
|
||||
|
||||
{!selected ? (
|
||||
<div className="mt-8 space-y-2">
|
||||
<div className="mx-auto mt-8 w-full max-w-5xl">
|
||||
{teamsLoading ? (
|
||||
<SkeletonLoader component="default" />
|
||||
) : teamsError ? (
|
||||
@@ -482,36 +547,66 @@ export default function Teams() {
|
||||
</Button>,
|
||||
)
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<button
|
||||
key={team.id}
|
||||
className="border-border bg-muted dark:bg-accent hover:border-primary/40 flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors"
|
||||
onClick={() => openTeam(team)}
|
||||
>
|
||||
<span aria-hidden="true" className="contents">
|
||||
<Avatar
|
||||
alt=""
|
||||
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-9 items-center justify-center rounded-md text-sm font-medium"
|
||||
>
|
||||
{initialOf(team.name)}
|
||||
</Avatar>
|
||||
</span>
|
||||
<span className="text-foreground min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{team.name}
|
||||
</span>
|
||||
{roleBadge(team.member_role ?? 'team_member')}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground shrink-0"
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
))
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{teams.map((team) => (
|
||||
<button
|
||||
key={team.id}
|
||||
className="border-border bg-muted dark:bg-accent hover:border-primary/40 group flex h-full flex-col gap-3 rounded-2xl border p-4 text-left transition-colors"
|
||||
onClick={() => openTeam(team)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span aria-hidden="true" className="contents">
|
||||
<Avatar
|
||||
alt=""
|
||||
className="bg-muted-foreground/15 text-foreground flex size-9 shrink-0 items-center justify-center rounded-md text-sm font-medium"
|
||||
>
|
||||
{initialOf(team.name)}
|
||||
</Avatar>
|
||||
</span>
|
||||
<span className="text-foreground min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{team.name}
|
||||
</span>
|
||||
{roleBadge(team.member_role ?? 'team_member')}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||
size={18}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
{team.description ? (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{team.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/50 text-xs italic">
|
||||
{t('settings.teams.noDescription')}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-auto flex items-center gap-1.5 text-xs">
|
||||
<Users size={13} strokeWidth={1.75} aria-hidden />
|
||||
<span>
|
||||
{t(
|
||||
(team.member_count ?? 0) === 1
|
||||
? 'settings.teams.memberCountOne'
|
||||
: 'settings.teams.memberCountOther',
|
||||
{ count: team.member_count ?? 0 },
|
||||
)}
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>
|
||||
{t('settings.teams.sharedCount', {
|
||||
count: team.shared_count ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 space-y-6">
|
||||
<div className="mx-auto mt-8 max-w-5xl space-y-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -532,29 +627,48 @@ export default function Teams() {
|
||||
<span aria-hidden="true" className="contents">
|
||||
<Avatar
|
||||
alt=""
|
||||
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-10 shrink-0 items-center justify-center rounded-md text-base font-medium"
|
||||
className="bg-muted-foreground/15 text-foreground flex size-10 shrink-0 items-center justify-center rounded-md text-base font-medium"
|
||||
>
|
||||
{initialOf(selected.name)}
|
||||
</Avatar>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.teams.label')} /
|
||||
</p>
|
||||
<h3 className="text-foreground truncate text-xl font-bold">
|
||||
{selected.name}
|
||||
</h3>
|
||||
{selected.description && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||
{selected.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => requestDeleteTeam(selected)}
|
||||
>
|
||||
{t('settings.teams.deleteTeam')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-label={t('settings.teams.teamActions')}
|
||||
>
|
||||
<MoreVertical size={18} strokeWidth={1.75} aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem onSelect={openEditModal}>
|
||||
<Pencil size={15} strokeWidth={1.75} aria-hidden />
|
||||
<span>{t('settings.teams.editTeam')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => requestDeleteTeam(selected)}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.75} aria-hidden />
|
||||
<span>{t('settings.teams.deleteTeam')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -565,9 +679,22 @@ export default function Teams() {
|
||||
)}
|
||||
|
||||
<div className="border-border mt-6 border-t pt-6">
|
||||
<h4 className="text-muted-foreground mb-3 text-xs font-semibold tracking-wide uppercase">
|
||||
{t('settings.teams.members')} · {members.length}
|
||||
</h4>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{t('settings.teams.members')} · {members.length}
|
||||
</h4>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={openAddMemberModal}
|
||||
>
|
||||
<Plus size={15} strokeWidth={1.75} aria-hidden />
|
||||
{t('settings.teams.addMember')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{members.length === 0 ? (
|
||||
emptyState(t('settings.teams.noMembers'))
|
||||
) : (
|
||||
@@ -580,7 +707,7 @@ export default function Teams() {
|
||||
<span aria-hidden="true" className="contents">
|
||||
<Avatar
|
||||
alt=""
|
||||
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-8 items-center justify-center rounded-full text-sm font-medium"
|
||||
className="bg-muted-foreground/15 text-foreground flex size-8 items-center justify-center rounded-full text-sm font-medium"
|
||||
>
|
||||
{initialOf(memberLabel(m))}
|
||||
</Avatar>
|
||||
@@ -638,49 +765,6 @@ export default function Teams() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="email"
|
||||
label={t('settings.teams.memberEmailLabel')}
|
||||
aria-label={t('settings.teams.memberEmailLabel')}
|
||||
placeholder={t('settings.teams.memberEmailPlaceholder')}
|
||||
value={newMemberEmail}
|
||||
labelBgClassName="bg-background"
|
||||
onChange={(e) => setNewMemberEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddMember()}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={newMemberRole}
|
||||
onValueChange={(value) => setNewMemberRole(value as TeamRole)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-[42px] sm:w-36"
|
||||
aria-label={t('settings.teams.memberRoleLabel')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="team_member">
|
||||
{t('settings.teams.roleMember')}
|
||||
</SelectItem>
|
||||
<SelectItem value="team_admin">
|
||||
{t('settings.teams.roleAdmin')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="h-[42px]"
|
||||
onClick={handleAddMember}
|
||||
disabled={!newMemberEmail.trim()}
|
||||
>
|
||||
{t('settings.teams.add')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border mt-6 border-t pt-6">
|
||||
@@ -768,6 +852,122 @@ export default function Teams() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={editOpen}
|
||||
onOpenChange={(open) => (open ? setEditOpen(true) : closeEditModal())}
|
||||
size="sm"
|
||||
mobileVariant="sheet"
|
||||
title={t('settings.teams.editTeam')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={closeEditModal}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={!editName.trim()}>
|
||||
{t('settings.teams.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
label={t('settings.teams.teamNamePlaceholder')}
|
||||
aria-label={t('settings.teams.teamNamePlaceholder')}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="team-edit-description"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t('settings.teams.descriptionLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
id="team-edit-description"
|
||||
rows={3}
|
||||
className="border-border bg-background text-foreground focus-visible:ring-ring/50 focus-visible:border-ring placeholder:text-muted-foreground w-full resize-none rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
placeholder={t('settings.teams.descriptionPlaceholder')}
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{editError && (
|
||||
<p className="text-destructive mt-3 text-sm" role="alert">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={addMemberOpen}
|
||||
onOpenChange={(open) =>
|
||||
open ? setAddMemberOpen(true) : closeAddMemberModal()
|
||||
}
|
||||
size="sm"
|
||||
mobileVariant="sheet"
|
||||
title={t('settings.teams.addMemberTitle')}
|
||||
description={t('settings.teams.addMemberDescription')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={closeAddMemberModal}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleAddMember} disabled={!newMemberEmail.trim()}>
|
||||
{t('settings.teams.add')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
type="email"
|
||||
autoFocus
|
||||
label={t('settings.teams.memberEmailLabel')}
|
||||
aria-label={t('settings.teams.memberEmailLabel')}
|
||||
placeholder={t('settings.teams.memberEmailPlaceholder')}
|
||||
value={newMemberEmail}
|
||||
onChange={(e) => setNewMemberEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddMember()}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="add-member-role"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t('settings.teams.memberRoleLabel')}
|
||||
</label>
|
||||
<Select
|
||||
value={newMemberRole}
|
||||
onValueChange={(value) => setNewMemberRole(value as TeamRole)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="add-member-role"
|
||||
aria-label={t('settings.teams.memberRoleLabel')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="team_member">
|
||||
{t('settings.teams.roleMember')}
|
||||
</SelectItem>
|
||||
<SelectItem value="team_admin">
|
||||
{t('settings.teams.roleAdmin')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{addMemberError && (
|
||||
<p className="text-destructive mt-3 text-sm" role="alert">
|
||||
{addMemberError}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmationModal
|
||||
message={t('settings.teams.deleteTeamConfirmation', {
|
||||
name: teamToDelete?.name ?? '',
|
||||
|
||||
@@ -9,6 +9,9 @@ export type Team = {
|
||||
description?: string | null;
|
||||
owner_id: string;
|
||||
member_role?: TeamRole;
|
||||
// Annotations from the list endpoint for the teams grid cards.
|
||||
member_count?: number;
|
||||
shared_count?: number;
|
||||
};
|
||||
|
||||
export type TeamsState = {
|
||||
|
||||
Reference in new issue
Block a user