Redesign teams portal and add user autocomplete for member invitations
- Redesign TeamsPage with modern card-based layout including stats bar, search functionality, and empty states - Add UserAutocomplete component with debounced search and keyboard navigation for selecting existing users - Add /api/v1/users/search endpoint for username prefix search - Update TeamMembersPage to use UserAutocomplete instead of free text input
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -48,6 +48,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Team-based access displayed as read-only with "Source" column indicating origin
|
||||
- Team members with access show team slug and role
|
||||
- Added integration tests for team CRUD, membership, and project operations
|
||||
- Redesigned teams portal with modern card-based layout
|
||||
- Card grid view with team avatar, name, slug, role badge, and stats
|
||||
- Stats bar showing total teams, owned teams, and total projects
|
||||
- Search functionality for filtering teams (appears when >3 teams)
|
||||
- Empty states for no teams and no search results
|
||||
- Added user autocomplete component for team member invitations
|
||||
- `GET /api/v1/users/search` endpoint for username prefix search
|
||||
- Dropdown shows matching users as you type
|
||||
- Keyboard navigation support (arrow keys, enter, escape)
|
||||
- Debounced search to reduce API calls
|
||||
- Added unit tests for TeamAuthorizationService
|
||||
- Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87)
|
||||
- When set, admin user is created with the specified password (no password change required)
|
||||
|
||||
@@ -1093,6 +1093,43 @@ def oidc_callback(
|
||||
return response
|
||||
|
||||
|
||||
# --- User Search Routes (for autocomplete) ---
|
||||
|
||||
|
||||
@router.get("/api/v1/users/search")
|
||||
def search_users(
|
||||
q: str = Query(..., min_length=1, description="Search query for username"),
|
||||
limit: int = Query(default=10, ge=1, le=50, description="Maximum results"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Search for users by username prefix.
|
||||
Returns basic user info for autocomplete (no email for privacy).
|
||||
Any authenticated user can search.
|
||||
"""
|
||||
search_pattern = f"{q.lower()}%"
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(
|
||||
func.lower(User.username).like(search_pattern),
|
||||
User.is_active == True,
|
||||
)
|
||||
.order_by(User.username)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"username": u.username,
|
||||
"is_admin": u.is_admin,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
# --- Admin User Management Routes ---
|
||||
|
||||
|
||||
|
||||
@@ -668,3 +668,17 @@ export async function listTeamProjects(
|
||||
});
|
||||
return handleResponse<PaginatedResponse<Project>>(response);
|
||||
}
|
||||
|
||||
// User search (for autocomplete)
|
||||
export interface UserSearchResult {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export async function searchUsers(query: string, limit: number = 10): Promise<UserSearchResult[]> {
|
||||
const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<UserSearchResult[]>(response);
|
||||
}
|
||||
|
||||
105
frontend/src/components/UserAutocomplete.css
Normal file
105
frontend/src/components/UserAutocomplete.css
Normal file
@@ -0,0 +1,105 @@
|
||||
.user-autocomplete {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-autocomplete__input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-autocomplete__input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.user-autocomplete__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
|
||||
.user-autocomplete__spinner {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: translateY(-50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
.user-autocomplete__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
padding: 0.25rem;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.user-autocomplete__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.user-autocomplete__option:hover,
|
||||
.user-autocomplete__option.selected {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.user-autocomplete__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-autocomplete__user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-autocomplete__username {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.user-autocomplete__admin-badge {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
171
frontend/src/components/UserAutocomplete.tsx
Normal file
171
frontend/src/components/UserAutocomplete.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { searchUsers, UserSearchResult } from '../api';
|
||||
import './UserAutocomplete.css';
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (username: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function UserAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search users...',
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
}: UserAutocompleteProps) {
|
||||
const [query, setQuery] = useState(value);
|
||||
const [results, setResults] = useState<UserSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Search for users with debounce
|
||||
const doSearch = useCallback(async (searchQuery: string) => {
|
||||
if (searchQuery.length < 1) {
|
||||
setResults([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const users = await searchUsers(searchQuery);
|
||||
setResults(users);
|
||||
setIsOpen(users.length > 0);
|
||||
setSelectedIndex(-1);
|
||||
} catch {
|
||||
setResults([]);
|
||||
setIsOpen(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle input change with debounce
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setQuery(newValue);
|
||||
onChange(newValue); // Update parent immediately for form validation
|
||||
|
||||
// Debounce the search
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
doSearch(newValue);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Handle selecting a user
|
||||
const handleSelect = (user: UserSearchResult) => {
|
||||
setQuery(user.username);
|
||||
onChange(user.username);
|
||||
setIsOpen(false);
|
||||
setResults([]);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
handleSelect(results[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Sync external value changes
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
}, [value]);
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="user-autocomplete" ref={containerRef}>
|
||||
<div className="user-autocomplete__input-wrapper">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => query.length >= 1 && results.length > 0 && setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
className="user-autocomplete__input"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="user-autocomplete__spinner" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && results.length > 0 && (
|
||||
<ul className="user-autocomplete__dropdown">
|
||||
{results.map((user, index) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className={`user-autocomplete__option ${index === selectedIndex ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(user)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="user-autocomplete__avatar">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-autocomplete__user-info">
|
||||
<span className="user-autocomplete__username">{user.username}</span>
|
||||
{user.is_admin && (
|
||||
<span className="user-autocomplete__admin-badge">Admin</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { Breadcrumb } from '../components/Breadcrumb';
|
||||
import { UserAutocomplete } from '../components/UserAutocomplete';
|
||||
import './TeamMembersPage.css';
|
||||
|
||||
function TeamMembersPage() {
|
||||
@@ -166,13 +167,10 @@ function TeamMembersPage() {
|
||||
<form onSubmit={handleAddMember}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
<UserAutocomplete
|
||||
value={newMember.username}
|
||||
onChange={e => setNewMember({ ...newMember, username: e.target.value })}
|
||||
placeholder="Enter username"
|
||||
required
|
||||
onChange={(username) => setNewMember({ ...newMember, username })}
|
||||
placeholder="Search for a user..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,89 +1,117 @@
|
||||
.teams-page {
|
||||
padding: 1.5rem 0;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
/* Header */
|
||||
.teams-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
.teams-header__content h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
.teams-header__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.team-name-cell {
|
||||
/* Stats */
|
||||
.teams-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.team-name-link {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.team-name-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.team-slug {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.team-description {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 2rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.teams-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.teams-stat__value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.teams-stat__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.teams-search {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.teams-search__icon {
|
||||
position: absolute;
|
||||
left: 0.875rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.teams-search__input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 2.5rem 0.625rem 2.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1rem;
|
||||
.teams-search__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 1.5rem;
|
||||
.teams-search__input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
.teams-search__clear {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.375rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.error-message {
|
||||
.teams-search__clear:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.teams-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -96,7 +124,7 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error-dismiss {
|
||||
.teams-error__dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
@@ -106,6 +134,145 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.teams-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.teams-loading__spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: teams-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes teams-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.teams-empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.teams-empty-icon {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.teams-empty-state h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.teams-empty-state p {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.teams-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Team Card */
|
||||
.team-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.team-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.team-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.team-card__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-hover, #2563eb));
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-card__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.team-card__title h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.team-card__slug {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.team-card__description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 1rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-card__footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.team-card__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.team-card__stat svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -124,15 +291,43 @@
|
||||
.modal-content {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.modal-content form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
@@ -147,13 +342,18 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group .optional {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9375rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -162,13 +362,34 @@
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-bg);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-alpha, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
|
||||
.input-with-prefix {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-right: none;
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.input-with-prefix input {
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -177,6 +398,8 @@
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
@@ -216,3 +439,23 @@
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.teams-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.teams-header .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.teams-stats {
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.teams-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { TeamDetail, TeamCreate, PaginatedResponse } from '../types';
|
||||
import { listTeams, createTeam } from '../api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { DataTable } from '../components/DataTable';
|
||||
import './TeamsPage.css';
|
||||
|
||||
function TeamsPage() {
|
||||
@@ -17,6 +16,7 @@ function TeamsPage() {
|
||||
const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [slugManuallySet, setSlugManuallySet] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const loadTeams = useCallback(async () => {
|
||||
try {
|
||||
@@ -65,16 +65,42 @@ function TeamsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const roleVariants: Record<string, 'success' | 'info' | 'default'> = {
|
||||
owner: 'success',
|
||||
admin: 'info',
|
||||
member: 'default',
|
||||
const closeModal = () => {
|
||||
setShowForm(false);
|
||||
setNewTeam({ name: '', slug: '', description: '' });
|
||||
setSlugManuallySet(false);
|
||||
};
|
||||
|
||||
// Filter teams by search
|
||||
const filteredTeams = teamsData?.items.filter(team =>
|
||||
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
team.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
) || [];
|
||||
|
||||
// Stats
|
||||
const totalTeams = teamsData?.items.length || 0;
|
||||
const totalProjects = teamsData?.items.reduce((sum, t) => sum + t.project_count, 0) || 0;
|
||||
const ownedTeams = teamsData?.items.filter(t => t.user_role === 'owner').length || 0;
|
||||
|
||||
const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = {
|
||||
owner: { variant: 'success', label: 'Owner' },
|
||||
admin: { variant: 'info', label: 'Admin' },
|
||||
member: { variant: 'default', label: 'Member' },
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="teams-page">
|
||||
<div className="empty-state">
|
||||
<div className="teams-empty-state">
|
||||
<div className="teams-empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Sign in to view your teams</h2>
|
||||
<p>Teams help you organize projects and collaborate with others.</p>
|
||||
<Link to="/login" className="btn btn-primary">Sign In</Link>
|
||||
@@ -83,76 +109,86 @@ function TeamsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Team',
|
||||
render: (team: TeamDetail) => (
|
||||
<div className="team-name-cell">
|
||||
<Link to={`/teams/${team.slug}`} className="team-name-link">
|
||||
{team.name}
|
||||
</Link>
|
||||
<span className="team-slug">@{team.slug}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Description',
|
||||
render: (team: TeamDetail) => (
|
||||
<span className="team-description">{team.description || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Your Role',
|
||||
render: (team: TeamDetail) => (
|
||||
team.user_role ? (
|
||||
<Badge variant={roleVariants[team.user_role] || 'default'}>
|
||||
{team.user_role}
|
||||
</Badge>
|
||||
) : null
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
header: 'Members',
|
||||
render: (team: TeamDetail) => team.member_count,
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
header: 'Projects',
|
||||
render: (team: TeamDetail) => team.project_count,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="teams-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="teams-header">
|
||||
<div className="teams-header__content">
|
||||
<h1>Teams</h1>
|
||||
<p className="page-subtitle">Organize projects and collaborate with others</p>
|
||||
<p className="teams-header__subtitle">Organize projects and collaborate with your team</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New Team
|
||||
Create Team
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="error-dismiss">×</button>
|
||||
{/* Stats */}
|
||||
{!loading && totalTeams > 0 && (
|
||||
<div className="teams-stats">
|
||||
<div className="teams-stat">
|
||||
<span className="teams-stat__value">{totalTeams}</span>
|
||||
<span className="teams-stat__label">Teams</span>
|
||||
</div>
|
||||
<div className="teams-stat">
|
||||
<span className="teams-stat__value">{ownedTeams}</span>
|
||||
<span className="teams-stat__label">Owned</span>
|
||||
</div>
|
||||
<div className="teams-stat">
|
||||
<span className="teams-stat__value">{totalProjects}</span>
|
||||
<span className="teams-stat__label">Projects</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{!loading && totalTeams > 3 && (
|
||||
<div className="teams-search">
|
||||
<svg className="teams-search__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search teams..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="teams-search__input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button className="teams-search__clear" onClick={() => setSearchQuery('')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="teams-error">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="teams-error__dismiss">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Team Modal */}
|
||||
{showForm && (
|
||||
<div className="modal-overlay" onClick={() => setShowForm(false)}>
|
||||
<div className="modal-overlay" onClick={closeModal}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create New Team</h2>
|
||||
<button className="modal-close" onClick={closeModal}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateTeam}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="team-name">Team Name</label>
|
||||
@@ -161,27 +197,30 @@ function TeamsPage() {
|
||||
type="text"
|
||||
value={newTeam.name}
|
||||
onChange={e => handleNameChange(e.target.value)}
|
||||
placeholder="My Team"
|
||||
placeholder="Engineering"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="team-slug">Slug</label>
|
||||
<label htmlFor="team-slug">URL Slug</label>
|
||||
<div className="input-with-prefix">
|
||||
<span className="input-prefix">@</span>
|
||||
<input
|
||||
id="team-slug"
|
||||
type="text"
|
||||
value={newTeam.slug}
|
||||
onChange={e => handleSlugChange(e.target.value)}
|
||||
placeholder="my-team"
|
||||
placeholder="engineering"
|
||||
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
|
||||
title="Lowercase letters, numbers, and hyphens only"
|
||||
required
|
||||
/>
|
||||
<span className="form-hint">Lowercase letters, numbers, and hyphens only</span>
|
||||
</div>
|
||||
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="team-description">Description (optional)</label>
|
||||
<label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
|
||||
<textarea
|
||||
id="team-description"
|
||||
value={newTeam.description}
|
||||
@@ -191,7 +230,7 @@ function TeamsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}>
|
||||
<button type="button" className="btn btn-secondary" onClick={closeModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={creating}>
|
||||
@@ -203,29 +242,85 @@ function TeamsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="loading-state">Loading teams...</div>
|
||||
) : teamsData?.items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<div className="teams-loading">
|
||||
<div className="teams-loading__spinner" />
|
||||
<span>Loading teams...</span>
|
||||
</div>
|
||||
) : filteredTeams.length === 0 ? (
|
||||
<div className="teams-empty-state">
|
||||
<div className="teams-empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<h2>No teams found</h2>
|
||||
<p>No teams match "{searchQuery}"</p>
|
||||
<button className="btn btn-secondary" onClick={() => setSearchQuery('')}>
|
||||
Clear search
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>No teams yet</h2>
|
||||
<p>Create your first team to start organizing your projects.</p>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
|
||||
Create Team
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={teamsData?.items || []}
|
||||
keyExtractor={team => team.id}
|
||||
onRowClick={team => navigate(`/teams/${team.slug}`)}
|
||||
/>
|
||||
<div className="teams-grid">
|
||||
{filteredTeams.map(team => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="team-card"
|
||||
onClick={() => navigate(`/teams/${team.slug}`)}
|
||||
>
|
||||
<div className="team-card__header">
|
||||
<div className="team-card__avatar">
|
||||
{team.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="team-card__title">
|
||||
<h3>{team.name}</h3>
|
||||
<span className="team-card__slug">@{team.slug}</span>
|
||||
</div>
|
||||
{team.user_role && (
|
||||
<Badge variant={roleConfig[team.user_role]?.variant || 'default'}>
|
||||
{roleConfig[team.user_role]?.label || team.user_role}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{team.description && (
|
||||
<p className="team-card__description">{team.description}</p>
|
||||
)}
|
||||
|
||||
<div className="team-card__footer">
|
||||
<div className="team-card__stat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
</svg>
|
||||
<span>{team.member_count} member{team.member_count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="team-card__stat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span>{team.project_count} project{team.project_count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user