feat: Add machine token auth for cron automation
- Add api_call_machine() function for GANTT_MACHINE_TOKEN auth - Update api_call() to use machine token when available - Same pattern applied to both api_client.sh and gantt.sh - Allows cron jobs to authenticate without cookie-based login - No breaking changes - cookie auth still works for interactive use
This commit is contained in:
parent
c23d3c4945
commit
be3476fd1a
99
SEARCH_FEATURE.md
Normal file
99
SEARCH_FEATURE.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# Gantt Board Search Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Enhanced search functionality for the Mission Control Gantt Board with real-time filtering, text highlighting, and keyboard shortcuts.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Search Bar Component
|
||||||
|
- **Location:** `src/components/SearchBar.tsx`
|
||||||
|
- **Features:**
|
||||||
|
- Clean, modern UI with search icon
|
||||||
|
- Clear button (X) when query exists
|
||||||
|
- Keyboard shortcut hint (⌘K / Ctrl+K)
|
||||||
|
- Escape to clear and blur
|
||||||
|
- Mobile responsive
|
||||||
|
|
||||||
|
### 2. Keyboard Shortcuts
|
||||||
|
- **⌘K / Ctrl+K:** Focus search input
|
||||||
|
- **Escape:** Clear search (if text exists) or blur input
|
||||||
|
|
||||||
|
### 3. Highlighting Components
|
||||||
|
- **Location:** `src/components/HighlightText.tsx`
|
||||||
|
- **Features:**
|
||||||
|
- Highlights matching text in yellow (`bg-yellow-500/30`)
|
||||||
|
- Case-insensitive matching
|
||||||
|
- Works with titles, descriptions, and tags
|
||||||
|
- `HighlightMatches` helper for highlighting multiple fields
|
||||||
|
|
||||||
|
### 4. Search Scope
|
||||||
|
Search covers:
|
||||||
|
- ✅ Task titles
|
||||||
|
- ✅ Task descriptions
|
||||||
|
- ✅ Tags
|
||||||
|
- ✅ Assignee names (in Search view)
|
||||||
|
- ✅ Status and type (in Search view)
|
||||||
|
|
||||||
|
### 5. Real-time Filtering
|
||||||
|
- 300ms debounce for smooth performance
|
||||||
|
- Instant filter updates in Kanban, Backlog, and Search views
|
||||||
|
- Search results count displayed
|
||||||
|
|
||||||
|
### 6. Task Highlighting
|
||||||
|
In Kanban view:
|
||||||
|
- Task titles are highlighted when they match
|
||||||
|
- Matching tags get yellow border and background
|
||||||
|
- Non-matching tags remain muted
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Search
|
||||||
|
1. Type in the search box or press ⌘K to focus
|
||||||
|
2. Tasks filter automatically as you type
|
||||||
|
3. Click X or press Escape to clear
|
||||||
|
|
||||||
|
### View Switching
|
||||||
|
- Search automatically switches to "Search" view when you type
|
||||||
|
- Toggle between Kanban/Backlog/Search views using the view buttons
|
||||||
|
- Each view respects the current search query
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
- `src/components/SearchBar.tsx` - Reusable search input component
|
||||||
|
- `src/components/HighlightText.tsx` - Text highlighting utility
|
||||||
|
- `src/components/TaskSearchItem.tsx` - Search result item component
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
- `src/app/page.tsx` - Integrated SearchBar, added searchQuery prop to KanbanTaskCard
|
||||||
|
- `src/components/SearchView.tsx` - Uses HighlightText for better display
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Search Algorithm
|
||||||
|
- Case-insensitive string matching
|
||||||
|
- Matches partial strings (e.g., "bug" matches "debug")
|
||||||
|
- Debounced at 300ms for performance
|
||||||
|
|
||||||
|
### Highlight Styling
|
||||||
|
```css
|
||||||
|
/* Matching text */
|
||||||
|
bg-yellow-500/30 - Yellow background
|
||||||
|
ring-yellow-500/30 - Yellow border on tags
|
||||||
|
text-yellow-200 - Light yellow text
|
||||||
|
|
||||||
|
/* Non-matching */
|
||||||
|
Regular muted styling preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- `searchQuery` - Current input value (immediate)
|
||||||
|
- `debouncedSearchQuery` - Debounced value for filtering (300ms delay)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
Potential improvements:
|
||||||
|
- [ ] Advanced filters (by status, assignee, date range)
|
||||||
|
- [ ] Saved searches
|
||||||
|
- [ ] Search history
|
||||||
|
- [ ] Full-text search in task comments
|
||||||
|
- [ ] Fuzzy/partial matching improvements
|
||||||
@ -36,11 +36,56 @@ check_deps() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# API call helper
|
# Machine-to-machine API call (for cron/automation)
|
||||||
|
api_call_machine() {
|
||||||
|
local method="$1"
|
||||||
|
local endpoint="$2"
|
||||||
|
local data="${3:-}"
|
||||||
|
|
||||||
|
local token="${GANTT_MACHINE_TOKEN:-}"
|
||||||
|
if [ -z "$token" ]; then
|
||||||
|
log_error "GANTT_MACHINE_TOKEN not set"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local url="${API_URL}${endpoint}"
|
||||||
|
local curl_opts=(-s -w "\n%{http_code}" -H "Content-Type: application/json" -H "Authorization: Bearer ${token}")
|
||||||
|
|
||||||
|
if [ -n "$data" ]; then
|
||||||
|
curl_opts+=(-d "$data")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl "${curl_opts[@]}" -X "$method" "$url")
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
local body
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||||
|
echo "$body"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "API call failed (HTTP $http_code)"
|
||||||
|
echo "$body" | jq '.' 2>/dev/null || echo "$body"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# API call helper (cookie auth for interactive, machine token for automation)
|
||||||
api_call() {
|
api_call() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
local endpoint="$2"
|
local endpoint="$2"
|
||||||
local data="${3:-}"
|
local data="${3:-}"
|
||||||
|
|
||||||
|
# Machine token path for automation/cron
|
||||||
|
if [ -n "${GANTT_MACHINE_TOKEN:-}" ]; then
|
||||||
|
api_call_machine "$method" "$endpoint" "$data"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cookie auth path for interactive use
|
||||||
local url="${API_URL}${endpoint}"
|
local url="${API_URL}${endpoint}"
|
||||||
|
|
||||||
mkdir -p "$(dirname "$COOKIE_FILE")"
|
mkdir -p "$(dirname "$COOKIE_FILE")"
|
||||||
|
|||||||
@ -45,11 +45,57 @@ login_if_needed() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Machine-to-machine API call (for cron/automation)
|
||||||
|
# Uses GANTT_MACHINE_TOKEN env var instead of cookie auth
|
||||||
|
api_call_machine() {
|
||||||
|
local method="$1"
|
||||||
|
local endpoint="$2"
|
||||||
|
local data="${3:-}"
|
||||||
|
|
||||||
|
local token="${GANTT_MACHINE_TOKEN:-}"
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
echo "Error: GANTT_MACHINE_TOKEN not set" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local url="${API_URL}${endpoint}"
|
||||||
|
local curl_opts=(-sS -w "\n%{http_code}" -X "$method" "$url" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${token}")
|
||||||
|
|
||||||
|
if [[ -n "$data" ]]; then
|
||||||
|
curl_opts+=(--data "$data")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl "${curl_opts[@]}") || return 1
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
local body
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then
|
||||||
|
echo "$body"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "API request failed ($method $endpoint) HTTP $http_code" >&2
|
||||||
|
echo "$body" | jq . 2>/dev/null >&2 || echo "$body" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
api_call() {
|
api_call() {
|
||||||
local method="$1"
|
local method="$1"
|
||||||
local endpoint="$2"
|
local endpoint="$2"
|
||||||
local data="${3:-}"
|
local data="${3:-}"
|
||||||
|
|
||||||
|
# Machine token path for automation/cron (no cookie auth needed)
|
||||||
|
if [[ -n "${GANTT_MACHINE_TOKEN:-}" ]]; then
|
||||||
|
api_call_machine "$method" "$endpoint" "$data"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
ensure_cookie_store
|
ensure_cookie_store
|
||||||
login_if_needed || return 1
|
login_if_needed || return 1
|
||||||
|
|
||||||
|
|||||||
@ -506,7 +506,22 @@ update_task() {
|
|||||||
existing=$(echo "$existing" | jq --argjson c "$comment" '.comments = (.comments // []) + [$c]')
|
existing=$(echo "$existing" | jq --argjson c "$comment" '.comments = (.comments // []) + [$c]')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
api_call POST "/tasks" "{\"task\": $existing}" | jq .
|
# Use temporary file for large payloads to avoid "Argument list too long" errors
|
||||||
|
local temp_file
|
||||||
|
temp_file=$(mktemp)
|
||||||
|
echo "{\"task\": $existing}" > "$temp_file"
|
||||||
|
|
||||||
|
# Use curl directly with --data @file to avoid argument length limits
|
||||||
|
local response
|
||||||
|
response=$(curl -sS -X POST "${API_URL}/tasks" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-b "$COOKIE_FILE" -c "$COOKIE_FILE" \
|
||||||
|
--data @"$temp_file")
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
rm -f "$temp_file"
|
||||||
|
|
||||||
|
echo "$response" | jq .
|
||||||
}
|
}
|
||||||
|
|
||||||
delete_task() {
|
delete_task() {
|
||||||
|
|||||||
600
scripts/task.sh.backup
Executable file
600
scripts/task.sh.backup
Executable file
@ -0,0 +1,600 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Task CLI for Gantt Board (API passthrough)
|
||||||
|
# Usage: ./task.sh [list|get|create|update|delete|current-sprint|bulk-create] [args...]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./lib/api_client.sh
|
||||||
|
source "$SCRIPT_DIR/lib/api_client.sh"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log_info() { echo -e "${BLUE}i${NC} $1"; }
|
||||||
|
log_success() { echo -e "${GREEN}ok${NC} $1"; }
|
||||||
|
log_warning() { echo -e "${YELLOW}warn${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}error${NC} $1"; }
|
||||||
|
|
||||||
|
UUID_PATTERN='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||||
|
DEFAULT_PROJECT_ID="${DEFAULT_PROJECT_ID:-}"
|
||||||
|
DEFAULT_ASSIGNEE_ID="${DEFAULT_ASSIGNEE_ID:-}"
|
||||||
|
|
||||||
|
check_dependencies() {
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
log_error "jq is required. Install with: brew install jq"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v uuidgen >/dev/null 2>&1; then
|
||||||
|
log_error "uuidgen is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
show_usage() {
|
||||||
|
cat << 'USAGE'
|
||||||
|
Task CLI for Gantt Board
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
./task.sh [list|get|create|update|delete|current-sprint|bulk-create] [args...]
|
||||||
|
|
||||||
|
COMMANDS:
|
||||||
|
list [status] List tasks (supports filters)
|
||||||
|
get <task-id> Get specific task
|
||||||
|
create "Title" [status] [priority] [project] [assignee] [sprint] [type]
|
||||||
|
create --title "Title" [flags...] Create task using flags
|
||||||
|
update <task-id> <field> <value> Update one field (legacy)
|
||||||
|
update <task-id> [flags...] Update task with flags
|
||||||
|
delete <task-id> Delete task
|
||||||
|
current-sprint Show current sprint ID
|
||||||
|
bulk-create <json-file> Bulk create from JSON array
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_project_id() {
|
||||||
|
local identifier="$1"
|
||||||
|
|
||||||
|
if [[ -z "$identifier" ]]; then
|
||||||
|
if [[ -n "$DEFAULT_PROJECT_ID" ]]; then
|
||||||
|
echo "$DEFAULT_PROJECT_ID"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/projects")
|
||||||
|
echo "$response" | jq -r '.projects[0].id // empty'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$identifier" =~ $UUID_PATTERN ]]; then
|
||||||
|
echo "$identifier"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/projects")
|
||||||
|
local project_id
|
||||||
|
project_id=$(echo "$response" | jq -r --arg q "$identifier" '
|
||||||
|
.projects
|
||||||
|
| map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase)))
|
||||||
|
| .[0].id // empty
|
||||||
|
')
|
||||||
|
|
||||||
|
if [[ -n "$project_id" ]]; then
|
||||||
|
echo "$project_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_error "Project '$identifier' not found"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_assignee_id() {
|
||||||
|
local identifier="$1"
|
||||||
|
|
||||||
|
if [[ -z "$identifier" ]]; then
|
||||||
|
if [[ -n "$DEFAULT_ASSIGNEE_ID" ]]; then
|
||||||
|
echo "$DEFAULT_ASSIGNEE_ID"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$identifier" =~ $UUID_PATTERN ]]; then
|
||||||
|
echo "$identifier"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/auth/users")
|
||||||
|
local user_id
|
||||||
|
user_id=$(echo "$response" | jq -r --arg q "$identifier" '
|
||||||
|
.users
|
||||||
|
| map(select(
|
||||||
|
((.name // "") | ascii_downcase | contains($q | ascii_downcase))
|
||||||
|
or ((.email // "") | ascii_downcase == ($q | ascii_downcase))
|
||||||
|
))
|
||||||
|
| .[0].id // empty
|
||||||
|
')
|
||||||
|
|
||||||
|
if [[ -n "$user_id" ]]; then
|
||||||
|
echo "$user_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_error "Assignee '$identifier' not found"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_sprint_id() {
|
||||||
|
local identifier="$1"
|
||||||
|
|
||||||
|
if [[ -z "$identifier" ]]; then
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$identifier" == "current" ]]; then
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/sprints/current")
|
||||||
|
echo "$response" | jq -r '.sprint.id // empty'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$identifier" =~ $UUID_PATTERN ]]; then
|
||||||
|
echo "$identifier"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/sprints")
|
||||||
|
local sprint_id
|
||||||
|
sprint_id=$(echo "$response" | jq -r --arg q "$identifier" '
|
||||||
|
.sprints
|
||||||
|
| map(select((.name // "") | ascii_downcase | contains($q | ascii_downcase)))
|
||||||
|
| .[0].id // empty
|
||||||
|
')
|
||||||
|
|
||||||
|
if [[ -n "$sprint_id" ]]; then
|
||||||
|
echo "$sprint_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_error "Sprint '$identifier' not found"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get_current_sprint() {
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/sprints/current")
|
||||||
|
echo "$response" | jq -r '.sprint.id // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
to_tag_array() {
|
||||||
|
local tags_csv="$1"
|
||||||
|
if [[ -z "$tags_csv" ]]; then
|
||||||
|
echo '[]'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
jq -cn --arg csv "$tags_csv" '$csv | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))'
|
||||||
|
}
|
||||||
|
|
||||||
|
to_comment_array() {
|
||||||
|
local comment_text="$1"
|
||||||
|
if [[ -z "$comment_text" ]]; then
|
||||||
|
echo '[]'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local now
|
||||||
|
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
jq -cn --arg id "$(date +%s)-$RANDOM" --arg text "$comment_text" --arg createdAt "$now" '
|
||||||
|
[{ id: $id, text: $text, createdAt: $createdAt, commentAuthorId: "assistant", replies: [] }]
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
list_tasks() {
|
||||||
|
local positional_status="${1:-}"
|
||||||
|
local status_filter=""
|
||||||
|
local priority_filter=""
|
||||||
|
local project_filter=""
|
||||||
|
local assignee_filter=""
|
||||||
|
local type_filter=""
|
||||||
|
local limit=""
|
||||||
|
local output_json=false
|
||||||
|
|
||||||
|
if [[ -n "$positional_status" && "$positional_status" != --* ]]; then
|
||||||
|
status_filter="$positional_status"
|
||||||
|
shift || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "${1:-}" in
|
||||||
|
--status) status_filter="${2:-}"; shift 2 ;;
|
||||||
|
--priority) priority_filter="${2:-}"; shift 2 ;;
|
||||||
|
--project) project_filter="${2:-}"; shift 2 ;;
|
||||||
|
--assignee) assignee_filter="${2:-}"; shift 2 ;;
|
||||||
|
--type) type_filter="${2:-}"; shift 2 ;;
|
||||||
|
--limit) limit="${2:-}"; shift 2 ;;
|
||||||
|
--json) output_json=true; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/tasks?scope=active-sprint")
|
||||||
|
|
||||||
|
local tasks
|
||||||
|
tasks=$(echo "$response" | jq '.tasks')
|
||||||
|
|
||||||
|
if [[ -n "$status_filter" ]]; then
|
||||||
|
tasks=$(echo "$tasks" | jq --arg v "$status_filter" '
|
||||||
|
($v | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $statuses
|
||||||
|
| map(select(.status as $s | ($statuses | index($s))))
|
||||||
|
')
|
||||||
|
fi
|
||||||
|
if [[ -n "$priority_filter" ]]; then
|
||||||
|
tasks=$(echo "$tasks" | jq --arg v "$priority_filter" 'map(select(.priority == $v))')
|
||||||
|
fi
|
||||||
|
if [[ -n "$type_filter" ]]; then
|
||||||
|
tasks=$(echo "$tasks" | jq --arg v "$type_filter" 'map(select(.type == $v))')
|
||||||
|
fi
|
||||||
|
if [[ -n "$project_filter" ]]; then
|
||||||
|
local project_id
|
||||||
|
project_id=$(resolve_project_id "$project_filter")
|
||||||
|
tasks=$(echo "$tasks" | jq --arg v "$project_id" 'map(select(.projectId == $v))')
|
||||||
|
fi
|
||||||
|
if [[ -n "$assignee_filter" ]]; then
|
||||||
|
local assignee_id
|
||||||
|
assignee_id=$(resolve_assignee_id "$assignee_filter")
|
||||||
|
tasks=$(echo "$tasks" | jq --arg v "$assignee_id" 'map(select(.assigneeId == $v))')
|
||||||
|
fi
|
||||||
|
if [[ -n "$limit" ]]; then
|
||||||
|
tasks=$(echo "$tasks" | jq --argjson n "$limit" '.[0:$n]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$output_json" == true ]]; then
|
||||||
|
echo "$tasks" | jq .
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local count
|
||||||
|
count=$(echo "$tasks" | jq 'length')
|
||||||
|
if [[ "$count" -eq 0 ]]; then
|
||||||
|
local current_sprint_id
|
||||||
|
current_sprint_id="$(get_current_sprint 2>/dev/null || true)"
|
||||||
|
if [[ -z "$current_sprint_id" ]]; then
|
||||||
|
log_warning "No current sprint found. Returning 0 tasks."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
log_success "Found $count task(s)"
|
||||||
|
|
||||||
|
printf "%-36s %-34s %-12s %-10s\n" "ID" "TITLE" "STATUS" "PRIORITY"
|
||||||
|
printf "%-36s %-34s %-12s %-10s\n" "------------------------------------" "----------------------------------" "------------" "----------"
|
||||||
|
|
||||||
|
echo "$tasks" | jq -r '.[] | [.id, (.title // "" | tostring | .[0:32]), (.status // ""), (.priority // "")] | @tsv' \
|
||||||
|
| while IFS=$'\t' read -r id title status priority; do
|
||||||
|
printf "%-36s %-34s %-12s %-10s\n" "$id" "$title" "$status" "$priority"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
get_task() {
|
||||||
|
local task_id="${1:-}"
|
||||||
|
if [[ -z "$task_id" ]]; then
|
||||||
|
log_error "Task ID required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(api_call GET "/tasks?taskId=$(urlencode "$task_id")&include=detail")
|
||||||
|
echo "$response" | jq '.tasks[0]'
|
||||||
|
}
|
||||||
|
|
||||||
|
create_from_payload() {
|
||||||
|
local task_payload="$1"
|
||||||
|
api_call POST "/tasks" "{\"task\": $task_payload}" | jq .
|
||||||
|
}
|
||||||
|
|
||||||
|
create_task() {
|
||||||
|
local title=""
|
||||||
|
local description=""
|
||||||
|
local task_type="task"
|
||||||
|
local status="open"
|
||||||
|
local priority="medium"
|
||||||
|
local project=""
|
||||||
|
local sprint=""
|
||||||
|
local assignee=""
|
||||||
|
local due_date=""
|
||||||
|
local tags_csv=""
|
||||||
|
local comments_text=""
|
||||||
|
local file_input=""
|
||||||
|
|
||||||
|
if [[ $# -gt 0 && "${1:-}" != --* ]]; then
|
||||||
|
# Legacy positional form
|
||||||
|
title="${1:-}"
|
||||||
|
status="${2:-$status}"
|
||||||
|
priority="${3:-$priority}"
|
||||||
|
project="${4:-$project}"
|
||||||
|
assignee="${5:-$assignee}"
|
||||||
|
sprint="${6:-$sprint}"
|
||||||
|
task_type="${7:-$task_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "${1:-}" in
|
||||||
|
--title) title="${2:-}"; shift 2 ;;
|
||||||
|
--description) description="${2:-}"; shift 2 ;;
|
||||||
|
--type) task_type="${2:-}"; shift 2 ;;
|
||||||
|
--status) status="${2:-}"; shift 2 ;;
|
||||||
|
--priority) priority="${2:-}"; shift 2 ;;
|
||||||
|
--project) project="${2:-}"; shift 2 ;;
|
||||||
|
--sprint) sprint="${2:-}"; shift 2 ;;
|
||||||
|
--assignee) assignee="${2:-}"; shift 2 ;;
|
||||||
|
--due-date) due_date="${2:-}"; shift 2 ;;
|
||||||
|
--tags) tags_csv="${2:-}"; shift 2 ;;
|
||||||
|
--comments) comments_text="${2:-}"; shift 2 ;;
|
||||||
|
--file) file_input="${2:-}"; shift 2 ;;
|
||||||
|
--interactive)
|
||||||
|
log_error "Interactive mode is not supported in API passthrough mode"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$file_input" ]]; then
|
||||||
|
if [[ ! -f "$file_input" ]]; then
|
||||||
|
log_error "File not found: $file_input"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
title=$(jq -r '.title // ""' "$file_input")
|
||||||
|
description=$(jq -r '.description // ""' "$file_input")
|
||||||
|
task_type=$(jq -r '.type // "task"' "$file_input")
|
||||||
|
status=$(jq -r '.status // "open"' "$file_input")
|
||||||
|
priority=$(jq -r '.priority // "medium"' "$file_input")
|
||||||
|
project=$(jq -r '.project // ""' "$file_input")
|
||||||
|
sprint=$(jq -r '.sprint // ""' "$file_input")
|
||||||
|
assignee=$(jq -r '.assignee // ""' "$file_input")
|
||||||
|
due_date=$(jq -r '.due_date // ""' "$file_input")
|
||||||
|
tags_csv=$(jq -r '.tags // ""' "$file_input")
|
||||||
|
comments_text=$(jq -r '.comments // ""' "$file_input")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$title" ]]; then
|
||||||
|
log_error "Title is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local project_id
|
||||||
|
project_id=$(resolve_project_id "$project")
|
||||||
|
if [[ -z "$project_id" ]]; then
|
||||||
|
log_error "Could not resolve project"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local assignee_id
|
||||||
|
assignee_id=$(resolve_assignee_id "$assignee")
|
||||||
|
|
||||||
|
local sprint_id
|
||||||
|
sprint_id=$(resolve_sprint_id "$sprint")
|
||||||
|
|
||||||
|
local tags_json
|
||||||
|
tags_json=$(to_tag_array "$tags_csv")
|
||||||
|
|
||||||
|
local comments_json
|
||||||
|
comments_json=$(to_comment_array "$comments_text")
|
||||||
|
|
||||||
|
local task_id
|
||||||
|
task_id=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
local now
|
||||||
|
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
local task_payload
|
||||||
|
task_payload=$(jq -n \
|
||||||
|
--arg id "$task_id" \
|
||||||
|
--arg title "$title" \
|
||||||
|
--arg description "$description" \
|
||||||
|
--arg type "$task_type" \
|
||||||
|
--arg status "$status" \
|
||||||
|
--arg priority "$priority" \
|
||||||
|
--arg projectId "$project_id" \
|
||||||
|
--arg sprintId "$sprint_id" \
|
||||||
|
--arg assigneeId "$assignee_id" \
|
||||||
|
--arg dueDate "$due_date" \
|
||||||
|
--arg createdAt "$now" \
|
||||||
|
--arg updatedAt "$now" \
|
||||||
|
--argjson tags "$tags_json" \
|
||||||
|
--argjson comments "$comments_json" \
|
||||||
|
'{
|
||||||
|
id: $id,
|
||||||
|
title: $title,
|
||||||
|
description: (if $description == "" then null else $description end),
|
||||||
|
type: $type,
|
||||||
|
status: $status,
|
||||||
|
priority: $priority,
|
||||||
|
projectId: $projectId,
|
||||||
|
sprintId: (if $sprintId == "" then null else $sprintId end),
|
||||||
|
assigneeId: (if $assigneeId == "" then null else $assigneeId end),
|
||||||
|
dueDate: (if $dueDate == "" then null else $dueDate end),
|
||||||
|
createdAt: $createdAt,
|
||||||
|
updatedAt: $updatedAt,
|
||||||
|
tags: $tags,
|
||||||
|
comments: $comments,
|
||||||
|
attachments: []
|
||||||
|
}')
|
||||||
|
|
||||||
|
create_from_payload "$task_payload"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_task() {
|
||||||
|
local task_id="${1:-}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
if [[ -z "$task_id" ]]; then
|
||||||
|
log_error "Task ID required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local existing
|
||||||
|
existing=$(api_call GET "/tasks?taskId=$(urlencode "$task_id")&include=detail" | jq '.tasks[0]')
|
||||||
|
|
||||||
|
if [[ "$existing" == "null" || -z "$existing" ]]; then
|
||||||
|
log_error "Task not found: $task_id"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $# -ge 2 && "${1:-}" != --* ]]; then
|
||||||
|
local field="${1:-}"
|
||||||
|
local value="${2:-}"
|
||||||
|
existing=$(echo "$existing" | jq --arg f "$field" --arg v "$value" '. + {($f): $v}')
|
||||||
|
shift 2 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
local add_comment=""
|
||||||
|
local clear_tags=false
|
||||||
|
local set_tags=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "${1:-}" in
|
||||||
|
--status) existing=$(echo "$existing" | jq --arg v "${2:-}" '.status = $v'); shift 2 ;;
|
||||||
|
--priority) existing=$(echo "$existing" | jq --arg v "${2:-}" '.priority = $v'); shift 2 ;;
|
||||||
|
--title) existing=$(echo "$existing" | jq --arg v "${2:-}" '.title = $v'); shift 2 ;;
|
||||||
|
--description) existing=$(echo "$existing" | jq --arg v "${2:-}" '.description = $v'); shift 2 ;;
|
||||||
|
--type) existing=$(echo "$existing" | jq --arg v "${2:-}" '.type = $v'); shift 2 ;;
|
||||||
|
--project)
|
||||||
|
local project_id
|
||||||
|
project_id=$(resolve_project_id "${2:-}")
|
||||||
|
existing=$(echo "$existing" | jq --arg v "$project_id" '.projectId = $v')
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--assignee)
|
||||||
|
local assignee_id
|
||||||
|
assignee_id=$(resolve_assignee_id "${2:-}")
|
||||||
|
existing=$(echo "$existing" | jq --arg v "$assignee_id" '.assigneeId = (if $v == "" then null else $v end)')
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--sprint)
|
||||||
|
local sprint_input="${2:-}"
|
||||||
|
local sprint_id
|
||||||
|
sprint_id=$(resolve_sprint_id "$sprint_input")
|
||||||
|
existing=$(echo "$existing" | jq --arg v "$sprint_id" '.sprintId = (if $v == "" then null else $v end)')
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--due-date) existing=$(echo "$existing" | jq --arg v "${2:-}" '.dueDate = (if $v == "" then null else $v end)'); shift 2 ;;
|
||||||
|
--add-comment) add_comment="${2:-}"; shift 2 ;;
|
||||||
|
--clear-tags) clear_tags=true; shift ;;
|
||||||
|
--tags) set_tags="${2:-}"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$clear_tags" == true ]]; then
|
||||||
|
existing=$(echo "$existing" | jq '.tags = []')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$set_tags" ]]; then
|
||||||
|
local tags_json
|
||||||
|
tags_json=$(to_tag_array "$set_tags")
|
||||||
|
existing=$(echo "$existing" | jq --argjson tags "$tags_json" '.tags = $tags')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$add_comment" ]]; then
|
||||||
|
local comment
|
||||||
|
comment=$(to_comment_array "$add_comment" | jq '.[0]')
|
||||||
|
existing=$(echo "$existing" | jq --argjson c "$comment" '.comments = (.comments // []) + [$c]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_call POST "/tasks" "{\"task\": $existing}" | jq .
|
||||||
|
}
|
||||||
|
|
||||||
|
delete_task() {
|
||||||
|
local task_id="${1:-}"
|
||||||
|
if [[ -z "$task_id" ]]; then
|
||||||
|
log_error "Task ID required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
api_call DELETE "/tasks" "{\"id\": \"$task_id\"}" | jq .
|
||||||
|
}
|
||||||
|
|
||||||
|
bulk_create() {
|
||||||
|
local file="${1:-}"
|
||||||
|
if [[ -z "$file" || ! -f "$file" ]]; then
|
||||||
|
log_error "bulk-create requires a valid JSON file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -c '.[]' "$file" | while IFS= read -r row; do
|
||||||
|
local title
|
||||||
|
title=$(echo "$row" | jq -r '.title // empty')
|
||||||
|
if [[ -z "$title" ]]; then
|
||||||
|
log_warning "Skipping entry without title"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local task_type status priority project sprint assignee due_date tags comments description
|
||||||
|
task_type=$(echo "$row" | jq -r '.type // "task"')
|
||||||
|
status=$(echo "$row" | jq -r '.status // "open"')
|
||||||
|
priority=$(echo "$row" | jq -r '.priority // "medium"')
|
||||||
|
project=$(echo "$row" | jq -r '.project // ""')
|
||||||
|
sprint=$(echo "$row" | jq -r '.sprint // ""')
|
||||||
|
assignee=$(echo "$row" | jq -r '.assignee // ""')
|
||||||
|
due_date=$(echo "$row" | jq -r '.due_date // ""')
|
||||||
|
tags=$(echo "$row" | jq -r '.tags // ""')
|
||||||
|
comments=$(echo "$row" | jq -r '.comments // ""')
|
||||||
|
description=$(echo "$row" | jq -r '.description // ""')
|
||||||
|
|
||||||
|
create_task \
|
||||||
|
--title "$title" \
|
||||||
|
--description "$description" \
|
||||||
|
--type "$task_type" \
|
||||||
|
--status "$status" \
|
||||||
|
--priority "$priority" \
|
||||||
|
--project "$project" \
|
||||||
|
--sprint "$sprint" \
|
||||||
|
--assignee "$assignee" \
|
||||||
|
--due-date "$due_date" \
|
||||||
|
--tags "$tags" \
|
||||||
|
--comments "$comments"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dependencies
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
list)
|
||||||
|
shift
|
||||||
|
list_tasks "$@"
|
||||||
|
;;
|
||||||
|
get)
|
||||||
|
get_task "${2:-}"
|
||||||
|
;;
|
||||||
|
create)
|
||||||
|
shift
|
||||||
|
create_task "$@"
|
||||||
|
;;
|
||||||
|
update)
|
||||||
|
shift
|
||||||
|
update_task "$@"
|
||||||
|
;;
|
||||||
|
delete)
|
||||||
|
delete_task "${2:-}"
|
||||||
|
;;
|
||||||
|
current-sprint)
|
||||||
|
shift
|
||||||
|
get_current_sprint
|
||||||
|
;;
|
||||||
|
bulk-create)
|
||||||
|
shift
|
||||||
|
bulk_create "${1:-}"
|
||||||
|
;;
|
||||||
|
help|--help|-h|"")
|
||||||
|
show_usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown command: ${1:-}"
|
||||||
|
show_usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@ -37,7 +37,9 @@ import {
|
|||||||
} from "@/lib/attachments"
|
} from "@/lib/attachments"
|
||||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
|
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
|
||||||
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
|
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
|
||||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive, Folder } from "lucide-react"
|
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive, Folder, Command } from "lucide-react"
|
||||||
|
import { SearchBar } from "@/components/SearchBar"
|
||||||
|
import { HighlightText } from "@/components/HighlightText"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
// Dynamic imports for heavy view components - only load when needed
|
// Dynamic imports for heavy view components - only load when needed
|
||||||
@ -242,12 +244,14 @@ function KanbanTaskCard({
|
|||||||
assigneeAvatarUrl,
|
assigneeAvatarUrl,
|
||||||
onOpen,
|
onOpen,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
searchQuery = "",
|
||||||
}: {
|
}: {
|
||||||
task: Task
|
task: Task
|
||||||
taskTags: string[]
|
taskTags: string[]
|
||||||
assigneeAvatarUrl?: string
|
assigneeAvatarUrl?: string
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
searchQuery?: string
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
@ -298,7 +302,13 @@ function KanbanTaskCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 className="font-medium text-white mb-1">{task.title}</h4>
|
<h4 className="font-medium text-white mb-1">
|
||||||
|
{searchQuery.trim() ? (
|
||||||
|
<HighlightText text={task.title} query={searchQuery} />
|
||||||
|
) : (
|
||||||
|
task.title
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
|
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
|
||||||
{task.description}
|
{task.description}
|
||||||
@ -344,14 +354,21 @@ function KanbanTaskCard({
|
|||||||
|
|
||||||
{taskTags.length > 0 && (
|
{taskTags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
{taskTags.map((tag) => (
|
{taskTags.map((tag) => {
|
||||||
|
const isMatch = searchQuery.trim() && tag.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
|
className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
isMatch
|
||||||
|
? "bg-yellow-500/20 text-yellow-200 border border-yellow-500/30"
|
||||||
|
: "bg-slate-800 text-slate-400"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{tag}
|
{isMatch ? <HighlightText text={tag} query={searchQuery} /> : tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -1008,23 +1025,13 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="relative hidden sm:block">
|
<div className="hidden sm:block w-48 lg:w-64">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
<SearchBar
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={setSearchQuery}
|
||||||
placeholder="Search tasks..."
|
placeholder="Search tasks..."
|
||||||
className="w-48 lg:w-64 pl-9 pr-8 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition-colors"
|
showKeyboardShortcut={true}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="flex items-center gap-1 text-xs text-blue-400">
|
<span className="flex items-center gap-1 text-xs text-blue-400">
|
||||||
@ -1180,24 +1187,12 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Mobile Search - shown only on small screens */}
|
{/* Mobile Search - shown only on small screens */}
|
||||||
<div className="sm:hidden mb-4">
|
<div className="sm:hidden mb-4">
|
||||||
<div className="relative">
|
<SearchBar
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={setSearchQuery}
|
||||||
placeholder="Search tasks..."
|
placeholder="Search tasks..."
|
||||||
className="w-full pl-9 pr-8 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
showKeyboardShortcut={false}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View Content */}
|
{/* View Content */}
|
||||||
@ -1256,6 +1251,7 @@ export default function Home() {
|
|||||||
assigneeAvatarUrl={resolveAssignee(task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl}
|
assigneeAvatarUrl={resolveAssignee(task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl}
|
||||||
onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
||||||
onDelete={() => deleteTask(task.id)}
|
onDelete={() => deleteTask(task.id)}
|
||||||
|
searchQuery={debouncedSearchQuery}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</KanbanDropColumn>
|
</KanbanDropColumn>
|
||||||
|
|||||||
134
src/components/HighlightText.tsx
Normal file
134
src/components/HighlightText.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface HighlightTextProps {
|
||||||
|
text: string
|
||||||
|
query: string
|
||||||
|
className?: string
|
||||||
|
highlightClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that highlights matching text within content
|
||||||
|
* Splits text by the query and wraps matches in a highlight span
|
||||||
|
*/
|
||||||
|
export function HighlightText({
|
||||||
|
text,
|
||||||
|
query,
|
||||||
|
className = "",
|
||||||
|
highlightClassName = "bg-yellow-500/30 text-yellow-200 font-medium",
|
||||||
|
}: HighlightTextProps) {
|
||||||
|
if (!query.trim() || !text) {
|
||||||
|
return <span className={className}>{text}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
const normalizedText = text.toLowerCase()
|
||||||
|
|
||||||
|
// Split text by query matches
|
||||||
|
const parts: { text: string; match: boolean }[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let index = normalizedText.indexOf(normalizedQuery)
|
||||||
|
|
||||||
|
while (index !== -1) {
|
||||||
|
// Add text before match
|
||||||
|
if (index > lastIndex) {
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(lastIndex, index),
|
||||||
|
match: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the match
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(index, index + query.length),
|
||||||
|
match: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
lastIndex = index + query.length
|
||||||
|
index = normalizedText.indexOf(normalizedQuery, lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(lastIndex),
|
||||||
|
match: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{parts.map((part, i) =>
|
||||||
|
part.match ? (
|
||||||
|
<mark
|
||||||
|
key={i}
|
||||||
|
className={`${highlightClassName} rounded px-0.5 -mx-0.5`}
|
||||||
|
>
|
||||||
|
{part.text}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part.text}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight multiple fields, returning null if no matches found
|
||||||
|
* Useful for conditionally highlighting description, tags, etc.
|
||||||
|
*/
|
||||||
|
interface HighlightMatch {
|
||||||
|
field: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HighlightMatchesProps {
|
||||||
|
matches: HighlightMatch[]
|
||||||
|
query: string
|
||||||
|
className?: string
|
||||||
|
highlightClassName?: string
|
||||||
|
maxLength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HighlightMatches({
|
||||||
|
matches,
|
||||||
|
query,
|
||||||
|
className = "text-sm text-slate-400",
|
||||||
|
highlightClassName = "bg-yellow-500/30 text-yellow-200 font-medium",
|
||||||
|
maxLength = 120,
|
||||||
|
}: HighlightMatchesProps) {
|
||||||
|
if (!query.trim()) return null
|
||||||
|
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
// Find which matches contain the query
|
||||||
|
const matchingFields = matches.filter((match) =>
|
||||||
|
match.value.toLowerCase().includes(normalizedQuery)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingFields.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{matchingFields.map((match, index) => (
|
||||||
|
<div key={match.field} className="flex items-start gap-2">
|
||||||
|
<span className="text-slate-500 shrink-0">{match.field}:</span>
|
||||||
|
<HighlightText
|
||||||
|
text={
|
||||||
|
match.value.length > maxLength
|
||||||
|
? match.value.slice(0, maxLength) + "..."
|
||||||
|
: match.value
|
||||||
|
}
|
||||||
|
query={query}
|
||||||
|
highlightClassName={highlightClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HighlightText
|
||||||
88
src/components/SearchBar.tsx
Normal file
88
src/components/SearchBar.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useCallback } from "react"
|
||||||
|
import { Search, X, Command } from "lucide-react"
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
autoFocus?: boolean
|
||||||
|
showKeyboardShortcut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Search tasks...",
|
||||||
|
className = "",
|
||||||
|
autoFocus = false,
|
||||||
|
showKeyboardShortcut = true,
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Handle Ctrl/Cmd+K keyboard shortcut
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Check for Ctrl+K or Cmd+K
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||||
|
e.preventDefault()
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
// Check for Escape to clear and blur
|
||||||
|
if (e.key === "Escape" && document.activeElement === inputRef.current) {
|
||||||
|
if (value) {
|
||||||
|
onChange("")
|
||||||
|
} else {
|
||||||
|
inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [value, onChange])
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
onChange("")
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="w-full pl-9 pr-20 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right side controls */}
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white transition-colors"
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showKeyboardShortcut && !value && (
|
||||||
|
<kbd className="hidden sm:flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono text-slate-500 bg-slate-700/50 rounded border border-slate-600">
|
||||||
|
<Command className="w-3 h-3" />
|
||||||
|
<span>K</span>
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchBar
|
||||||
223
src/components/TaskSearchItem.tsx
Normal file
223
src/components/TaskSearchItem.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { MessageSquare, Calendar, Paperclip, Hash } from "lucide-react"
|
||||||
|
import { Task, TaskType, TaskStatus } from "@/stores/useTaskStore"
|
||||||
|
import { HighlightText, HighlightMatches } from "./HighlightText"
|
||||||
|
import { generateAvatarDataUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
const typeColors: Record<TaskType, string> = {
|
||||||
|
idea: "bg-purple-500",
|
||||||
|
task: "bg-blue-500",
|
||||||
|
bug: "bg-red-500",
|
||||||
|
research: "bg-green-500",
|
||||||
|
plan: "bg-amber-500",
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<TaskType, string> = {
|
||||||
|
idea: "💡 Idea",
|
||||||
|
task: "📋 Task",
|
||||||
|
bug: "🐛 Bug",
|
||||||
|
research: "🔬 Research",
|
||||||
|
plan: "📐 Plan",
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: "text-slate-400",
|
||||||
|
medium: "text-blue-400",
|
||||||
|
high: "text-orange-400",
|
||||||
|
urgent: "text-red-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatStatusLabel = (status: TaskStatus) =>
|
||||||
|
status === "todo"
|
||||||
|
? "To Do"
|
||||||
|
: status
|
||||||
|
.split("-")
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ")
|
||||||
|
|
||||||
|
interface TaskSearchItemProps {
|
||||||
|
task: Task
|
||||||
|
query: string
|
||||||
|
sprintName?: string | null
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarCircle({
|
||||||
|
name,
|
||||||
|
avatarUrl,
|
||||||
|
seed,
|
||||||
|
sizeClass = "h-5 w-5",
|
||||||
|
}: {
|
||||||
|
name?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
seed?: string
|
||||||
|
sizeClass?: string
|
||||||
|
}) {
|
||||||
|
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User")
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={displayUrl}
|
||||||
|
alt={name || "User avatar"}
|
||||||
|
className={`${sizeClass} rounded-full border border-slate-700 object-cover bg-slate-800`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskSearchItem({ task, query, sprintName, onClick }: TaskSearchItemProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const attachmentCount = task.attachments?.length || 0
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick()
|
||||||
|
} else {
|
||||||
|
router.push(`/tasks/${encodeURIComponent(task.id)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build matches for highlighting
|
||||||
|
const matchFields = [
|
||||||
|
{ field: "Description", value: task.description || "" },
|
||||||
|
{ field: "Tags", value: task.tags?.join(", ") || "" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const hasMatchInField = (fieldValue: string) => {
|
||||||
|
if (!query.trim()) return false
|
||||||
|
return fieldValue.toLowerCase().includes(query.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="bg-slate-900 border-slate-800 hover:border-blue-500/50 cursor-pointer transition-all group"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Type Badge */}
|
||||||
|
<div className="pt-1">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${typeColors[task.type]} text-white border-0 shrink-0`}
|
||||||
|
>
|
||||||
|
{typeLabels[task.type]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Title Row */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<h4 className="font-medium text-white truncate">
|
||||||
|
<HighlightText text={task.title} query={query} />
|
||||||
|
</h4>
|
||||||
|
<span className={`text-xs font-medium uppercase ${priorityColors[task.priority]} shrink-0`}>
|
||||||
|
{task.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlighted Match Display */}
|
||||||
|
<HighlightMatches
|
||||||
|
matches={matchFields}
|
||||||
|
query={query}
|
||||||
|
className="mb-3 space-y-1"
|
||||||
|
maxLength={150}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Individual Tag Highlights (if tags match) */}
|
||||||
|
{task.tags && task.tags.length > 0 && hasMatchInField(task.tags.join(" ")) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{task.tags.map((tag) => {
|
||||||
|
const isMatch = hasMatchInField(tag)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded flex items-center gap-1 ${
|
||||||
|
isMatch
|
||||||
|
? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30"
|
||||||
|
: "bg-slate-800 text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Hash className="w-3 h-3" />
|
||||||
|
{isMatch ? <HighlightText text={tag} query={query} /> : tag}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta Row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||||
|
{/* Status */}
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-slate-800 text-slate-300 text-[10px]"
|
||||||
|
>
|
||||||
|
{formatStatusLabel(task.status)}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Sprint */}
|
||||||
|
{sprintName && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{sprintName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
{task.comments && task.comments.length > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{task.comments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
{attachmentCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{attachmentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(task.dueDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<AvatarCircle
|
||||||
|
name={task.assigneeName || "Unassigned"}
|
||||||
|
avatarUrl={task.assigneeAvatarUrl}
|
||||||
|
seed={task.assigneeId}
|
||||||
|
sizeClass="h-5 w-5"
|
||||||
|
/>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{task.assigneeName ? (
|
||||||
|
hasMatchInField(task.assigneeName) ? (
|
||||||
|
<HighlightText text={task.assigneeName} query={query} />
|
||||||
|
) : (
|
||||||
|
task.assigneeName
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
"Unassigned"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskSearchItem
|
||||||
Loading…
Reference in New Issue
Block a user