refactor(ui): remove deprecated project components
Delete unused project components: - Board: BoardColumn, BoardHeader, BoardListView, BoardModals, KanbanBoard - Channel: RoomSettingsModal, AiSettings - Issue: IssueSidebar, ReactionBar - Skills: CreateSkillDialog, DeleteSkillDialog - ProjectCreateMenuModal
This commit is contained in:
parent
f6f69a063e
commit
2b543f5e37
@ -1,96 +0,0 @@
|
||||
import { Plus, X, User } from "lucide-react";
|
||||
import { BOARD_PAGE } from "@/css/app/board-styles";
|
||||
import type { CardResponse } from "@/client/model";
|
||||
|
||||
interface BoardColumnProps {
|
||||
column: {
|
||||
column: { id: string; name: string };
|
||||
cards: CardResponse[];
|
||||
};
|
||||
allColumns: { column: { id: string; name: string } }[];
|
||||
onAddCard: (columnId: string) => void;
|
||||
onDeleteColumn: (columnId: string) => void;
|
||||
onCardClick: (card: CardResponse) => void;
|
||||
onMoveCard: (cardId: string, targetColumnId: string) => void;
|
||||
}
|
||||
|
||||
export function BoardColumn({
|
||||
column,
|
||||
allColumns,
|
||||
onAddCard,
|
||||
onDeleteColumn,
|
||||
onCardClick,
|
||||
onMoveCard,
|
||||
}: BoardColumnProps) {
|
||||
return (
|
||||
<div className={BOARD_PAGE.column}>
|
||||
<div className={BOARD_PAGE.columnHead}>
|
||||
<div className={BOARD_PAGE.columnTitle}>
|
||||
{column.column.name}
|
||||
<span className={BOARD_PAGE.columnBadge}>{column.cards.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className={BOARD_PAGE.iconBtn}
|
||||
onClick={() => onAddCard(column.column.id)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className={BOARD_PAGE.iconBtn}
|
||||
onClick={() => onDeleteColumn(column.column.id)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={BOARD_PAGE.columnBody}>
|
||||
{column.cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={BOARD_PAGE.card}
|
||||
onClick={() => onCardClick(card)}
|
||||
>
|
||||
<h4 className={BOARD_PAGE.cardTitle}>{card.title}</h4>
|
||||
{card.description && (
|
||||
<p className="text-[11px] text-muted-foreground line-clamp-2 mb-2 leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
)}
|
||||
<div className={BOARD_PAGE.cardFooter}>
|
||||
<div className={BOARD_PAGE.cardMeta}>
|
||||
<User className="w-3 h-3" />
|
||||
<span>Assignee</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{allColumns.map((targetCol) =>
|
||||
targetCol.column.id !== column.column.id ? (
|
||||
<button
|
||||
key={targetCol.column.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveCard(card.id, targetCol.column.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted text-[9px] font-bold border border-border"
|
||||
title={`Move to ${targetCol.column.name}`}
|
||||
>
|
||||
{targetCol.column.name[0]}
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className={BOARD_PAGE.addBtn}
|
||||
onClick={() => onAddCard(column.column.id)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Plus, Trash2, ArrowLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string
|
||||
boardName: string
|
||||
boardDescription?: string
|
||||
onDeleteBoard: () => void
|
||||
onAddColumn: () => void
|
||||
}
|
||||
|
||||
export function BoardHeader({
|
||||
projectName,
|
||||
boardName,
|
||||
boardDescription,
|
||||
onDeleteBoard,
|
||||
onAddColumn,
|
||||
}: BoardHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/70 bg-card/80 p-5 shadow-sm backdrop-blur">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={() => navigate(`/${projectName}/board`)}
|
||||
title="Back to boards"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<h1 className="truncate text-2xl font-semibold tracking-tight text-foreground">
|
||||
{boardName}
|
||||
</h1>
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
/{projectName}
|
||||
</span>
|
||||
</div>
|
||||
{boardDescription && (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
{boardDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={onAddColumn}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
>
|
||||
<Plus />
|
||||
Add column
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDeleteBoard}
|
||||
title="Delete board"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useBoardsQuery } from "@/hooks/useBoardsQuery";
|
||||
import { useBoardOperations } from "@/hooks/useBoardOperations";
|
||||
import { LoadingState } from "@/components/ui/LoadingState";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
X,
|
||||
Trash2,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { BOARD_PAGE } from "@/css/app/board-styles";
|
||||
|
||||
interface BoardListViewProps {
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function BoardListView({ projectName }: BoardListViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: boards = [], isLoading } = useBoardsQuery(projectName);
|
||||
const ops = useBoardOperations(projectName);
|
||||
const [isCreateBoardOpen, setIsCreateBoardOpen] = useState(false);
|
||||
const [newBoard, setNewBoard] = useState({ name: "", description: "" });
|
||||
|
||||
const handleCreateBoard = async () => {
|
||||
if (!newBoard.name.trim()) return;
|
||||
await ops.createBoard.mutateAsync({
|
||||
name: newBoard.name.trim(),
|
||||
description: newBoard.description.trim() || undefined,
|
||||
});
|
||||
setIsCreateBoardOpen(false);
|
||||
setNewBoard({ name: "", description: "" });
|
||||
};
|
||||
|
||||
const handleDeleteBoard = async (id: string) => {
|
||||
if (confirm("Are you sure you want to delete this board?")) {
|
||||
await ops.deleteBoard.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <LoadingState message="Loading boards..." />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={BOARD_PAGE.container}>
|
||||
<div className={BOARD_PAGE.header}>
|
||||
<div className={BOARD_PAGE.titleGroup}>
|
||||
<h1 className={BOARD_PAGE.title}>Boards</h1>
|
||||
<p className={BOARD_PAGE.description}>
|
||||
Organize and track your project tasks
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCreateBoardOpen(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-accent text-accent-fg hover:bg-accent/90 transition-colors text-[13px] font-medium shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Board
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{boards.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<LayoutGrid className="w-8 h-8 opacity-20" />}
|
||||
title="No boards yet"
|
||||
description="Create a Kanban board to start managing your workflow."
|
||||
/>
|
||||
) : (
|
||||
<div className={BOARD_PAGE.grid}>
|
||||
{boards.map((board) => (
|
||||
<div
|
||||
key={board.id}
|
||||
onClick={() => navigate(`/${projectName}/board/${board.id}`)}
|
||||
className={BOARD_PAGE.boardCard}
|
||||
>
|
||||
<div className={BOARD_PAGE.boardIcon}>
|
||||
<LayoutGrid className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className={BOARD_PAGE.boardName}>{board.name}</h3>
|
||||
<p className={BOARD_PAGE.boardDesc}>
|
||||
{board.description || "No description provided."}
|
||||
</p>
|
||||
<div className={BOARD_PAGE.boardMeta}>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
Created {new Date(board.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto p-1 text-muted-foreground hover:text-red-500 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteBoard(board.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Board Modal */}
|
||||
{isCreateBoardOpen && (
|
||||
<div className={BOARD_PAGE.modal}>
|
||||
<div className={`${BOARD_PAGE.modalContent} max-w-md`}>
|
||||
<div className={BOARD_PAGE.modalHead}>
|
||||
<h3 className="font-bold text-[15px]">New Board</h3>
|
||||
<button
|
||||
className={BOARD_PAGE.iconBtn}
|
||||
onClick={() => setIsCreateBoardOpen(false)}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={BOARD_PAGE.modalBody}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
Board Name
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full text-[14px] px-3 py-2 rounded-md border border-border bg-muted/10 outline-none focus:ring-1 focus:ring-accent"
|
||||
placeholder="e.g. Sprint Planning"
|
||||
value={newBoard.name}
|
||||
onChange={(e) =>
|
||||
setNewBoard((b) => ({ ...b, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full text-[14px] px-3 py-2 rounded-md border border-border bg-muted/10 outline-none focus:ring-1 focus:ring-accent min-h-[80px]"
|
||||
placeholder="Optional details..."
|
||||
value={newBoard.description}
|
||||
onChange={(e) =>
|
||||
setNewBoard((b) => ({ ...b, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={BOARD_PAGE.modalFooter}>
|
||||
<button
|
||||
onClick={() => setIsCreateBoardOpen(false)}
|
||||
className="px-4 py-1.5 rounded-md text-[13px] hover:bg-muted font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateBoard}
|
||||
disabled={!newBoard.name.trim() || ops.createBoard.isPending}
|
||||
className="px-4 py-1.5 rounded-md bg-accent text-accent-fg font-bold text-[13px] disabled:opacity-50"
|
||||
>
|
||||
Create Board
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,265 +0,0 @@
|
||||
import { X, Loader2, Save } from "lucide-react";
|
||||
import { BOARD_PAGE } from "@/css/app/board-styles";
|
||||
import type { CardResponse } from "@/client/model";
|
||||
import { t } from "@/i18n/T";
|
||||
|
||||
interface BoardModalsProps {
|
||||
// Column modal
|
||||
isCreateColumnOpen: boolean;
|
||||
newColumnName: string;
|
||||
isCreatingColumn: boolean;
|
||||
onNewColumnNameChange: (name: string) => void;
|
||||
onCreateColumn: () => void;
|
||||
onCloseColumn: () => void;
|
||||
// Card modal
|
||||
isCreateCardOpen: string | false;
|
||||
newCardTitle: string;
|
||||
newCardDescription: string;
|
||||
isCreatingCard: boolean;
|
||||
onNewCardTitleChange: (title: string) => void;
|
||||
onNewCardDescriptionChange: (desc: string) => void;
|
||||
onCreateCard: () => void;
|
||||
onCloseCard: () => void;
|
||||
// Card detail modal
|
||||
selectedCard: CardResponse | null;
|
||||
editCardTitle: string;
|
||||
editCardDescription: string;
|
||||
isUpdatingCard: boolean;
|
||||
onEditTitleChange: (title: string) => void;
|
||||
onEditDescriptionChange: (desc: string) => void;
|
||||
onUpdateCard: () => void;
|
||||
onDeleteCard: () => void;
|
||||
onCloseDetail: () => void;
|
||||
}
|
||||
|
||||
export function BoardModals({
|
||||
isCreateColumnOpen,
|
||||
newColumnName,
|
||||
isCreatingColumn,
|
||||
onNewColumnNameChange,
|
||||
onCreateColumn,
|
||||
onCloseColumn,
|
||||
isCreateCardOpen,
|
||||
newCardTitle,
|
||||
newCardDescription,
|
||||
isCreatingCard,
|
||||
onNewCardTitleChange,
|
||||
onNewCardDescriptionChange,
|
||||
onCreateCard,
|
||||
onCloseCard,
|
||||
selectedCard,
|
||||
editCardTitle,
|
||||
editCardDescription,
|
||||
isUpdatingCard,
|
||||
onEditTitleChange,
|
||||
onEditDescriptionChange,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onCloseDetail,
|
||||
}: BoardModalsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Create Column Modal */}
|
||||
{isCreateColumnOpen && (
|
||||
<div className={BOARD_PAGE.modal}>
|
||||
<div className={`${BOARD_PAGE.modalContent} max-w-md`}>
|
||||
<div className={BOARD_PAGE.modalHead}>
|
||||
<h3 className="font-bold text-[15px]">{t("project.board.add_column_title")}</h3>
|
||||
<button className={BOARD_PAGE.iconBtn} onClick={onCloseColumn}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={BOARD_PAGE.modalBody}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{t("project.board.column_name")}
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full text-[14px] px-3 py-2 rounded-md border border-border bg-muted/10 outline-none focus:ring-1 focus:ring-accent"
|
||||
placeholder={t("project.board.column_name_placeholder")}
|
||||
value={newColumnName}
|
||||
onChange={(e) => onNewColumnNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={BOARD_PAGE.modalFooter}>
|
||||
<button
|
||||
onClick={onCloseColumn}
|
||||
className="px-4 py-1.5 rounded-md text-[13px] hover:bg-muted font-medium"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreateColumn}
|
||||
disabled={!newColumnName.trim() || isCreatingColumn}
|
||||
className="px-4 py-1.5 rounded-md bg-accent text-accent-fg font-bold text-[13px] disabled:opacity-50"
|
||||
>
|
||||
{t("project.board.add_column")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Card Modal */}
|
||||
{isCreateCardOpen && (
|
||||
<div className={BOARD_PAGE.modal}>
|
||||
<div className={`${BOARD_PAGE.modalContent} max-w-md`}>
|
||||
<div className={BOARD_PAGE.modalHead}>
|
||||
<h3 className="font-bold text-[15px]">{t("project.board.new_card")}</h3>
|
||||
<button className={BOARD_PAGE.iconBtn} onClick={onCloseCard}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={BOARD_PAGE.modalBody}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{t("project.board.card_title")}
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full text-[14px] px-3 py-2 rounded-md border border-border bg-muted/10 outline-none focus:ring-1 focus:ring-accent"
|
||||
placeholder={t("project.board.card_title_placeholder")}
|
||||
value={newCardTitle}
|
||||
onChange={(e) => onNewCardTitleChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{t("project.settings.general.description")}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full text-[14px] px-3 py-2 rounded-md border border-border bg-muted/10 outline-none focus:ring-1 focus:ring-accent min-h-[100px]"
|
||||
placeholder={t("project.board.card_desc_placeholder")}
|
||||
value={newCardDescription}
|
||||
onChange={(e) => onNewCardDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={BOARD_PAGE.modalFooter}>
|
||||
<button
|
||||
onClick={onCloseCard}
|
||||
className="px-4 py-1.5 rounded-md text-[13px] hover:bg-muted font-medium"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreateCard}
|
||||
disabled={!newCardTitle.trim() || isCreatingCard}
|
||||
className="px-4 py-1.5 rounded-md bg-accent text-accent-fg font-bold text-[13px] disabled:opacity-50"
|
||||
>
|
||||
{t("project.board.add_card")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Detail & Edit Modal */}
|
||||
{selectedCard && (
|
||||
<div className={BOARD_PAGE.modal}>
|
||||
<div
|
||||
className={`${BOARD_PAGE.modalContent} max-w-7xl min-h-[700px] flex flex-col`}
|
||||
>
|
||||
<div className={BOARD_PAGE.modalHead}>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-[12px] font-medium uppercase tracking-wider">
|
||||
{t("project.board.card_detail_title")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={BOARD_PAGE.iconBtn}
|
||||
onClick={onDeleteCard}
|
||||
title={t("project.board.delete_card")}
|
||||
>
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
<button className={BOARD_PAGE.iconBtn} onClick={onCloseDetail}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${BOARD_PAGE.modalBody} flex-1 overflow-y-auto`}>
|
||||
<div className="flex gap-12">
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 space-y-10">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] font-bold text-muted-foreground uppercase flex items-center gap-2 tracking-[0.2em] ml-1">
|
||||
{t("project.board.card_detail_title")}
|
||||
</label>
|
||||
<input
|
||||
className="w-full text-[28px] font-bold px-1 py-1 rounded-md border-b border-transparent hover:border-border focus:border-accent bg-transparent outline-none transition-all placeholder:opacity-30"
|
||||
value={editCardTitle}
|
||||
onChange={(e) => onEditTitleChange(e.target.value)}
|
||||
placeholder={t("project.board.card_detail_title")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] font-bold text-muted-foreground uppercase flex items-center gap-2 tracking-[0.2em] ml-1">
|
||||
{t("project.settings.general.description")}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full text-[16px] px-6 py-6 rounded-2xl border border-border bg-muted/5 focus:bg-background outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent min-h-[400px] leading-relaxed transition-all shadow-inner"
|
||||
placeholder={t("project.board.card_detail_desc_placeholder")}
|
||||
value={editCardDescription}
|
||||
onChange={(e) => onEditDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Attributes */}
|
||||
<div className="w-60 space-y-8 pt-4 shrink-0">
|
||||
<div className="pt-8 border-t border-border/50 space-y-3 px-1">
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground">{t("project.board.id")}</span>
|
||||
<span className="text-foreground font-mono opacity-60">
|
||||
#{selectedCard.id.substring(0, 6)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground">{t("project.board.created")}</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{new Date(selectedCard.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground">{t("project.board.updated")}</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{new Date(selectedCard.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={BOARD_PAGE.modalFooter}>
|
||||
<button
|
||||
onClick={onCloseDetail}
|
||||
className="px-5 py-2 rounded-lg text-[13px] font-semibold text-muted-foreground hover:bg-muted transition-all"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateCard}
|
||||
disabled={!editCardTitle.trim() || isUpdatingCard}
|
||||
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-accent text-accent-fg font-bold text-[13px] shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{isUpdatingCard ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{t("common.actions.save_changes")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,181 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useBoardDetailQuery } from "@/hooks/useBoardsQuery";
|
||||
import { useBoardOperations } from "@/hooks/useBoardOperations";
|
||||
import { LoadingState } from "@/components/ui/LoadingState";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { AlertCircle, Plus } from "lucide-react";
|
||||
import type { CardResponse } from "@/client/model";
|
||||
import { BOARD_PAGE } from "@/css/app/board-styles";
|
||||
import { BoardHeader } from "./BoardHeader";
|
||||
import { BoardColumn } from "./BoardColumn";
|
||||
import { BoardModals } from "./BoardModals";
|
||||
|
||||
interface KanbanBoardProps {
|
||||
projectName: string;
|
||||
boardId: string;
|
||||
}
|
||||
|
||||
export function KanbanBoard({ projectName, boardId }: KanbanBoardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: boardDetail, isLoading, refetch } = useBoardDetailQuery(projectName, boardId);
|
||||
const ops = useBoardOperations(projectName);
|
||||
|
||||
const [isCreateColumnOpen, setIsCreateColumnOpen] = useState(false);
|
||||
const [isCreateCardOpen, setIsCreateCardOpen] = useState<string | false>(false);
|
||||
const [selectedCard, setSelectedCard] = useState<CardResponse | null>(null);
|
||||
const [newColumnName, setNewColumnName] = useState("");
|
||||
const [newCardTitle, setNewCardTitle] = useState("");
|
||||
const [newCardDescription, setNewCardDescription] = useState("");
|
||||
const [editCardTitle, setEditCardTitle] = useState("");
|
||||
const [editCardDescription, setEditCardDescription] = useState("");
|
||||
|
||||
|
||||
const handleCreateColumn = async () => {
|
||||
if (!newColumnName.trim()) return;
|
||||
await ops.createColumn.mutateAsync({
|
||||
boardId,
|
||||
params: { name: newColumnName.trim(), position: boardDetail?.columns.length || 0 },
|
||||
});
|
||||
setIsCreateColumnOpen(false);
|
||||
setNewColumnName("");
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleCreateCard = async () => {
|
||||
if (!newCardTitle.trim() || !isCreateCardOpen) return;
|
||||
await ops.createCard.mutateAsync({
|
||||
column_id: isCreateCardOpen,
|
||||
title: newCardTitle.trim(),
|
||||
description: newCardDescription.trim() || undefined,
|
||||
});
|
||||
setIsCreateCardOpen(false);
|
||||
setNewCardTitle("");
|
||||
setNewCardDescription("");
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleUpdateCard = async () => {
|
||||
if (!selectedCard || !editCardTitle.trim()) return;
|
||||
await ops.updateCard.mutateAsync({
|
||||
id: selectedCard.id,
|
||||
params: { title: editCardTitle.trim(), description: editCardDescription.trim() || null },
|
||||
});
|
||||
setSelectedCard(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleMoveCard = async (cardId: string, targetColumnId: string) => {
|
||||
await ops.moveCard.mutateAsync({
|
||||
id: cardId,
|
||||
params: { target_column_id: targetColumnId, position: 0 },
|
||||
});
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDeleteBoard = async () => {
|
||||
if (confirm("Are you sure you want to delete this board?")) {
|
||||
await ops.deleteBoard.mutateAsync(boardId);
|
||||
navigate(`/${projectName}/board`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCard = async () => {
|
||||
if (!selectedCard) return;
|
||||
if (confirm("Delete this card?")) {
|
||||
await ops.deleteCard.mutateAsync(selectedCard.id);
|
||||
setSelectedCard(null);
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <LoadingState message="Opening board..." />;
|
||||
if (!boardDetail)
|
||||
return (
|
||||
<ErrorState
|
||||
title="Board not found"
|
||||
message="This board might have been deleted."
|
||||
onRetry={() => navigate(`/${projectName}/board`)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={BOARD_PAGE.container}>
|
||||
<BoardHeader
|
||||
projectName={projectName}
|
||||
boardName={boardDetail.board.name}
|
||||
boardDescription={boardDetail.board.description ?? undefined}
|
||||
onDeleteBoard={handleDeleteBoard}
|
||||
onAddColumn={() => setIsCreateColumnOpen(true)}
|
||||
/>
|
||||
|
||||
<div className={BOARD_PAGE.kanbanWrapper}>
|
||||
{boardDetail.columns.map((col) => (
|
||||
<BoardColumn
|
||||
key={col.column.id}
|
||||
column={col}
|
||||
allColumns={boardDetail.columns}
|
||||
onAddCard={(columnId) => setIsCreateCardOpen(columnId)}
|
||||
onDeleteColumn={(columnId) => {
|
||||
if (confirm("Delete column?")) {
|
||||
ops.deleteColumn.mutate(columnId);
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
onCardClick={(card) => {
|
||||
setSelectedCard(card);
|
||||
setEditCardTitle(card.title);
|
||||
setEditCardDescription(card.description || "");
|
||||
}}
|
||||
onMoveCard={handleMoveCard}
|
||||
/>
|
||||
))}
|
||||
|
||||
{boardDetail.columns.length === 0 && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-border/40 rounded-2xl bg-muted/5 min-h-[300px]">
|
||||
<AlertCircle className="w-10 h-10 opacity-10 mb-4" />
|
||||
<h4 className="text-[14px] font-bold text-muted-foreground">Empty Board</h4>
|
||||
<p className="text-[12px] text-muted-foreground/60 mt-1 mb-6 text-center max-w-[240px]">
|
||||
Add your first column (e.g. To Do, Doing, Done) to start tracking work.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsCreateColumnOpen(true)}
|
||||
className="flex items-center gap-1.5 px-5 py-2 rounded-lg bg-accent text-accent-fg font-bold text-[13px]"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add First Column
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BoardModals
|
||||
isCreateColumnOpen={isCreateColumnOpen}
|
||||
newColumnName={newColumnName}
|
||||
isCreatingColumn={ops.createColumn.isPending}
|
||||
onNewColumnNameChange={setNewColumnName}
|
||||
onCreateColumn={handleCreateColumn}
|
||||
onCloseColumn={() => { setIsCreateColumnOpen(false); setNewColumnName(""); }}
|
||||
isCreateCardOpen={isCreateCardOpen}
|
||||
newCardTitle={newCardTitle}
|
||||
newCardDescription={newCardDescription}
|
||||
isCreatingCard={ops.createCard.isPending}
|
||||
onNewCardTitleChange={setNewCardTitle}
|
||||
onNewCardDescriptionChange={setNewCardDescription}
|
||||
onCreateCard={handleCreateCard}
|
||||
onCloseCard={() => { setIsCreateCardOpen(false); setNewCardTitle(""); setNewCardDescription(""); }}
|
||||
selectedCard={selectedCard}
|
||||
editCardTitle={editCardTitle}
|
||||
editCardDescription={editCardDescription}
|
||||
isUpdatingCard={ops.updateCard.isPending}
|
||||
onEditTitleChange={setEditCardTitle}
|
||||
onEditDescriptionChange={setEditCardDescription}
|
||||
onUpdateCard={handleUpdateCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onCloseDetail={() => setSelectedCard(null)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,219 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings, Trash2, Loader2, Globe, Lock } from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useOptionalRoom } from "@/contexts/room";
|
||||
import { AiSettings } from "./settings/AiSettings";
|
||||
import { roomUpdate, roomDelete } from "@/client/api";
|
||||
import { toast } from "sonner";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useCreateCategoryMutation, useInvalidateRooms, useRoomsQuery } from "@/hooks/useRoomsQuery";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface RoomSettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function RoomSettingsModal({ open, onOpenChange }: RoomSettingsModalProps) {
|
||||
const roomCtx = useOptionalRoom();
|
||||
const currentRoom = roomCtx?.currentRoom ?? null;
|
||||
const setCurrentRoom = roomCtx?.setCurrentRoom ?? (() => {});
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: roomsData } = useRoomsQuery(projectName);
|
||||
const createCategory = useCreateCategoryMutation(projectName);
|
||||
const invalidateRooms = useInvalidateRooms();
|
||||
const categories = roomsData?.categories ?? [];
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [category, setCategory] = useState("none");
|
||||
const [newCategory, setNewCategory] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !currentRoom) return;
|
||||
setName(currentRoom.room_name);
|
||||
setIsPublic(currentRoom.public ?? true);
|
||||
setCategory(currentRoom.category ?? "none");
|
||||
setNewCategory("");
|
||||
}, [currentRoom, open]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
onOpenChange(newOpen);
|
||||
if (newOpen && currentRoom) {
|
||||
setName(currentRoom.room_name);
|
||||
setIsPublic(currentRoom.public ?? true);
|
||||
setCategory(currentRoom.category ?? "none");
|
||||
setNewCategory("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRoom = async () => {
|
||||
if (!currentRoom || !name.trim()) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let categoryId: string | null = category === "none" ? null : category;
|
||||
if (category === "new") {
|
||||
if (!newCategory.trim()) {
|
||||
toast.error("Enter a group name");
|
||||
return;
|
||||
}
|
||||
const createdCategory = await createCategory.mutateAsync({
|
||||
name: newCategory.trim(),
|
||||
position: categories.length,
|
||||
});
|
||||
categoryId = createdCategory?.id ?? null;
|
||||
}
|
||||
await roomUpdate(currentRoom.id, { room_name: name, room_public: isPublic, category: categoryId });
|
||||
setCurrentRoom({ ...currentRoom, room_name: name, public: isPublic, category: categoryId });
|
||||
if (projectName) invalidateRooms(projectName);
|
||||
toast.success("Room settings updated");
|
||||
} catch {
|
||||
toast.error("Failed to update room");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRoom = async () => {
|
||||
if (!currentRoom) return;
|
||||
if (!confirm("Are you sure you want to delete this room? This action cannot be undone.")) return;
|
||||
|
||||
try {
|
||||
await roomDelete(currentRoom.id);
|
||||
toast.success("Room deleted");
|
||||
onOpenChange(false);
|
||||
navigate(`/${projectName}/channel`);
|
||||
} catch {
|
||||
toast.error("Failed to delete room");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg p-0 flex flex-col" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
<SheetHeader className="p-6 pb-0 shrink-0">
|
||||
<SheetTitle className="flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<Settings className="w-5 h-5" />
|
||||
Room Settings
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
{/* General Section */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider mb-4" style={{ color: "var(--text-muted)" }}>
|
||||
General
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" style={{ color: "var(--text-secondary)" }}>
|
||||
Room Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. general"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
style={{ borderColor: "var(--border-subtle)" }}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{isPublic ? (
|
||||
<Globe className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
) : (
|
||||
<Lock className="w-4 h-4" style={{ color: "var(--text-muted)" }} />
|
||||
)}
|
||||
<Label className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
Public Room
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
Anyone in the project can join this room.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={isPublic} onCheckedChange={setIsPublic} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label style={{ color: "var(--text-secondary)" }}>
|
||||
Group
|
||||
</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">No group</SelectItem>
|
||||
{categories.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>{item.name}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="new">Create new group</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{category === "new" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-category" style={{ color: "var(--text-secondary)" }}>
|
||||
New Group Name
|
||||
</Label>
|
||||
<Input
|
||||
id="new-category"
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
placeholder="e.g. Engineering"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI Agents */}
|
||||
{currentRoom && <AiSettings roomId={currentRoom.id} />}
|
||||
|
||||
{/* Danger Zone */}
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold mb-3" style={{ color: "var(--destructive)" }}>
|
||||
Danger Zone
|
||||
</h4>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleDeleteRoom}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Room
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 shrink-0 flex justify-end gap-2"
|
||||
style={{ borderTop: "1px solid var(--border-subtle)", backgroundColor: "var(--surface-ground)" }}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSaving || !name.trim()}
|
||||
onClick={handleUpdateRoom}
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@ -1,585 +0,0 @@
|
||||
import { Loader2, Shield, Search, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { aiList, aiUpsert, aiDelete, modelCatalog } from "@/client/api";
|
||||
import type { RoomAiUpsertRequest, ModelWithPricingResponse, RoomAiResponse } from "@/client/model";
|
||||
import { getModelIcon } from "@/lib/icons/modelIcons";
|
||||
import { Plus, Trash2, Settings, X as XIcon } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
|
||||
"#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6",
|
||||
"#06b6d4", "#3b82f6", "#2563eb", "#7c3aed", "#c026d3",
|
||||
];
|
||||
|
||||
function hashColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
function ModelAvatar({ modelName, size = 36 }: { modelName: string; size?: number }) {
|
||||
const Icon = getModelIcon(modelName);
|
||||
if (Icon) {
|
||||
return <Icon.Avatar size={size} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg flex items-center justify-center font-bold shrink-0"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: hashColor(modelName),
|
||||
fontSize: Math.max(10, size * 0.35),
|
||||
color: "var(--text-inverse)",
|
||||
}}
|
||||
>
|
||||
{modelName[0]?.toUpperCase() || "?"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AiSettingsProps {
|
||||
roomId: string;
|
||||
onAiListChange?: () => void;
|
||||
}
|
||||
|
||||
export function AiSettings({ roomId, onAiListChange }: AiSettingsProps) {
|
||||
const [roomAis, setRoomAis] = useState<RoomAiResponse[]>([]);
|
||||
const [isLoadingAi, setIsLoadingAi] = useState(true);
|
||||
const [showAddAi, setShowAddAi] = useState(false);
|
||||
const [selectedModelFull, setSelectedModelFull] = useState<ModelWithPricingResponse | null>(null);
|
||||
const [aiParams, setAiParams] = useState({
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
stream: true,
|
||||
system_prompt: "",
|
||||
});
|
||||
const [isAddingAi, setIsAddingAi] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
|
||||
const { data: catalog, isLoading: isLoadingCatalog } = useQuery({
|
||||
queryKey: ["modelCatalog"],
|
||||
queryFn: async () => {
|
||||
const res = await modelCatalog();
|
||||
return (res.data.data as unknown as { data: ModelWithPricingResponse[] }).data;
|
||||
},
|
||||
enabled: showAddAi,
|
||||
});
|
||||
|
||||
const models = Array.isArray(catalog) ? catalog : [];
|
||||
const filteredCatalog = modelSearch.trim()
|
||||
? models.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(modelSearch.toLowerCase()) ||
|
||||
m.provider_id.toLowerCase().includes(modelSearch.toLowerCase())
|
||||
)
|
||||
: models;
|
||||
|
||||
const fetchRoomAis = async () => {
|
||||
setIsLoadingAi(true);
|
||||
try {
|
||||
const res = await aiList(roomId);
|
||||
setRoomAis(res.data.data || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch room AIs", err);
|
||||
} finally {
|
||||
setIsLoadingAi(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
aiList(roomId)
|
||||
.then((res) => setRoomAis(res.data.data || []))
|
||||
.catch((err) => console.error("Failed to fetch room AIs", err))
|
||||
.finally(() => setIsLoadingAi(false));
|
||||
}, [roomId]);
|
||||
|
||||
const handleAddAi = async () => {
|
||||
if (!selectedModelFull) return;
|
||||
setIsAddingAi(true);
|
||||
try {
|
||||
const req: RoomAiUpsertRequest = {
|
||||
model: selectedModelFull.id,
|
||||
stream: aiParams.stream,
|
||||
temperature: aiParams.temperature,
|
||||
max_tokens: aiParams.max_tokens,
|
||||
system_prompt: aiParams.system_prompt || undefined,
|
||||
};
|
||||
await aiUpsert(roomId, req);
|
||||
fetchRoomAis();
|
||||
setShowAddAi(false);
|
||||
setSelectedModelFull(null);
|
||||
onAiListChange?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to add AI model", err);
|
||||
} finally {
|
||||
setIsAddingAi(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAi = async (modelId: string) => {
|
||||
try {
|
||||
await aiDelete(roomId, modelId);
|
||||
fetchRoomAis();
|
||||
onAiListChange?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to remove AI agent", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>
|
||||
AI Agents
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 h-8"
|
||||
onClick={() => setShowAddAi(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add AI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingAi ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--text-muted)" }} />
|
||||
</div>
|
||||
) : roomAis.length === 0 ? (
|
||||
<div className="text-center py-10 border-2 border-dashed rounded-xl" style={{ borderColor: "var(--border-subtle)" }}>
|
||||
<Shield className="w-8 h-8 mx-auto mb-2" style={{ color: "var(--text-muted)", opacity: 0.3 }} />
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
No AI agents active in this room
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{roomAis.map((ai) => (
|
||||
<div
|
||||
key={ai.model}
|
||||
className="flex items-center justify-between p-3 rounded-lg border group"
|
||||
style={{ borderColor: "var(--border-subtle)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ModelAvatar modelName={ai.model_name || ai.model} size={36} />
|
||||
<div>
|
||||
<div
|
||||
className="font-semibold"
|
||||
style={{ color: "var(--text-primary)", fontSize: "13px" }}
|
||||
>
|
||||
{ai.model_name || "Unknown Model"}
|
||||
</div>
|
||||
<div
|
||||
className="line-clamp-1"
|
||||
style={{ color: "var(--text-muted)", fontSize: "11px" }}
|
||||
>
|
||||
{ai.model}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="h-8 w-8 rounded-md flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--destructive)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
}}
|
||||
onClick={() => handleRemoveAi(ai.model)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
open={showAddAi}
|
||||
onOpenChange={(open) => {
|
||||
setShowAddAi(open);
|
||||
if (!open) {
|
||||
setSelectedModelFull(null);
|
||||
setModelSearch("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="!max-w-[80vw] w-[80vw] h-[85vh] !max-h-[85vh] !gap-0 !p-0 overflow-hidden !rounded-xl"
|
||||
style={{ backgroundColor: "var(--surface-ground)" }}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加 AI 模型</DialogTitle>
|
||||
|
||||
<div className="flex h-full w-full">
|
||||
{/* ── Left Sidebar: Model List ── */}
|
||||
<div
|
||||
className="w-[280px] shrink-0 flex flex-col h-full"
|
||||
style={{ borderRight: "1px solid var(--border-subtle)" }}
|
||||
>
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3
|
||||
className="text-[13px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
选择模型
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddAi(false);
|
||||
setSelectedModelFull(null);
|
||||
}}
|
||||
className="p-1 rounded-md transition-colors"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索模型..."
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
className="w-full h-9 pl-8 pr-3 text-sm rounded-lg outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model list */}
|
||||
<div className="flex-1 overflow-y-auto px-2">
|
||||
{isLoadingCatalog ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: "var(--text-muted)" }} />
|
||||
</div>
|
||||
) : filteredCatalog.length === 0 ? (
|
||||
<div className="text-center py-8 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
暂无模型
|
||||
</div>
|
||||
) : (
|
||||
filteredCatalog.map((model) => {
|
||||
const isActive = selectedModelFull?.id === model.id;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
setSelectedModelFull(model);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2.5 rounded-lg transition-colors flex items-center gap-3 text-sm mb-0.5"
|
||||
style={{
|
||||
backgroundColor: isActive ? "var(--hover-bg-strong)" : "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<ModelAvatar modelName={model.name} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="truncate font-medium"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{model.name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 shrink-0" style={{ color: "var(--accent)" }} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] truncate"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{model.provider_id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Panel: Model Details & Config ── */}
|
||||
<div className="flex-1 flex flex-col min-w-0 h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[660px] px-10 py-8">
|
||||
{!selectedModelFull ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[400px]">
|
||||
<Shield
|
||||
className="w-12 h-12 mb-4"
|
||||
style={{ color: "var(--text-muted)", opacity: 0.2 }}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
从左侧选择一个 AI 模型查看详情
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Model Info Card */}
|
||||
<div
|
||||
className="flex items-center gap-4 p-5 rounded-xl border"
|
||||
style={{ borderColor: "var(--border-subtle)" }}
|
||||
>
|
||||
<ModelAvatar modelName={selectedModelFull.name} size={56} />
|
||||
<div className="min-w-0">
|
||||
<h3
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{selectedModelFull.name}
|
||||
</h3>
|
||||
<div
|
||||
className="text-sm font-mono"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{selectedModelFull.provider_id}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--accent-muted)",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
{selectedModelFull.capability}
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--interactive)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{selectedModelFull.context_length.toLocaleString()} ctx
|
||||
</span>
|
||||
{selectedModelFull.max_output_tokens && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--interactive)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
最大输出 {selectedModelFull.max_output_tokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-xl border"
|
||||
style={{ borderColor: "var(--border-subtle)" }}
|
||||
>
|
||||
<div
|
||||
className="text-[11px] font-semibold uppercase tracking-wider mb-1"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
输入价格
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{selectedModelFull.input_price ?? "N/A"}{" "}
|
||||
<span className="text-sm font-normal" style={{ color: "var(--text-muted)" }}>
|
||||
{selectedModelFull.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-4 rounded-xl border"
|
||||
style={{ borderColor: "var(--border-subtle)" }}
|
||||
>
|
||||
<div
|
||||
className="text-[11px] font-semibold uppercase tracking-wider mb-1"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
输出价格
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{selectedModelFull.output_price ?? "N/A"}{" "}
|
||||
<span className="text-sm font-normal" style={{ color: "var(--text-muted)" }}>
|
||||
{selectedModelFull.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ borderColor: "var(--border-subtle)" }} />
|
||||
|
||||
{/* Parameter Configuration */}
|
||||
<section>
|
||||
<h4
|
||||
className="text-xs font-bold uppercase tracking-widest mb-4 flex items-center gap-2"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" /> 参数配置
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Temperature
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={aiParams.temperature}
|
||||
onChange={(e) =>
|
||||
setAiParams((p) => ({
|
||||
...p,
|
||||
temperature: parseFloat(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
className="w-full h-9 text-sm px-3 rounded-lg border outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border-subtle)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Max Tokens
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={selectedModelFull.max_output_tokens || 128000}
|
||||
step={1}
|
||||
value={aiParams.max_tokens}
|
||||
onChange={(e) =>
|
||||
setAiParams((p) => ({
|
||||
...p,
|
||||
max_tokens: parseInt(e.target.value) || 1,
|
||||
}))
|
||||
}
|
||||
className="w-full h-9 text-sm px-3 rounded-lg border outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border-subtle)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
System Prompt
|
||||
</Label>
|
||||
<textarea
|
||||
className="w-full min-h-[100px] text-sm p-3 rounded-lg border resize-y outline-none"
|
||||
style={{
|
||||
backgroundColor: "var(--input-bg)",
|
||||
borderColor: "var(--border-subtle)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
placeholder="You are a helpful assistant..."
|
||||
value={aiParams.system_prompt}
|
||||
onChange={(e) =>
|
||||
setAiParams((p) => ({ ...p, system_prompt: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 rounded-xl border"
|
||||
style={{ borderColor: "var(--border-subtle)" }}
|
||||
>
|
||||
<div>
|
||||
<Label
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Stream Response
|
||||
</Label>
|
||||
<p
|
||||
className="text-[11px] mt-0.5"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
启用流式响应以实时输出
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={aiParams.stream}
|
||||
onCheckedChange={(v) =>
|
||||
setAiParams((p) => ({ ...p, stream: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Confirm Button */}
|
||||
<Button
|
||||
onClick={handleAddAi}
|
||||
disabled={isAddingAi || !selectedModelFull}
|
||||
className="w-full font-semibold h-11"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--accent-fg)",
|
||||
}}
|
||||
>
|
||||
{isAddingAi ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : null}
|
||||
确认添加
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,530 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useCreateRepoMutation } from "@/hooks/useReposQuery";
|
||||
import { useCreateCategoryMutation, useCreateRoomMutation, useRoomsQuery } from "@/hooks/useRoomsQuery";
|
||||
import { useBoardOperations } from "@/hooks/useBoardOperations";
|
||||
import { useCreateSkillMutation } from "@/hooks/useSkillsQuery";
|
||||
import { projectInviteUser } from "@/client/api";
|
||||
import type { MemberRole } from "@/client/model";
|
||||
import {
|
||||
X,
|
||||
Hash,
|
||||
Plus,
|
||||
Loader2,
|
||||
Globe,
|
||||
Lock,
|
||||
PlusCircle,
|
||||
FolderPlus,
|
||||
MessageSquarePlus,
|
||||
Kanban,
|
||||
Zap,
|
||||
Mail,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { t } from "@/i18n/T";
|
||||
|
||||
interface ProjectCreateMenuModalProps {
|
||||
onClose: () => void;
|
||||
initialTab?: "repo" | "channel" | "board" | "skill" | "invite";
|
||||
}
|
||||
|
||||
export function ProjectCreateMenuModal({ onClose, initialTab = "repo" }: ProjectCreateMenuModalProps) {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"repo" | "channel" | "board" | "skill" | "invite">(initialTab);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// --- Repo Form State ---
|
||||
const [repoForm, setRepoForm] = useState({
|
||||
repo_name: "",
|
||||
description: "",
|
||||
is_private: false
|
||||
});
|
||||
const createRepo = useCreateRepoMutation(projectName);
|
||||
|
||||
// --- Channel Form State ---
|
||||
const [channelForm, setChannelForm] = useState({
|
||||
room_name: "",
|
||||
is_public: true,
|
||||
category: "none",
|
||||
new_category: "",
|
||||
});
|
||||
const createRoom = useCreateRoomMutation(projectName);
|
||||
const createCategory = useCreateCategoryMutation(projectName);
|
||||
const { data: roomsData } = useRoomsQuery(projectName);
|
||||
const categories = roomsData?.categories ?? [];
|
||||
|
||||
// --- Board Form State ---
|
||||
const [boardForm, setBoardForm] = useState({
|
||||
name: "",
|
||||
description: ""
|
||||
});
|
||||
const boardOps = useBoardOperations(projectName);
|
||||
|
||||
// --- Skill Form State ---
|
||||
const [skillForm, setSkillForm] = useState({
|
||||
name: "",
|
||||
description: ""
|
||||
});
|
||||
const createSkill = useCreateSkillMutation(projectName);
|
||||
|
||||
// --- Invite Form State ---
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: "",
|
||||
scope: "Member" as MemberRole,
|
||||
});
|
||||
|
||||
const handleCreateRepo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!repoForm.repo_name.trim()) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await createRepo.mutateAsync({
|
||||
repo_name: repoForm.repo_name.trim(),
|
||||
description: repoForm.description.trim() || undefined,
|
||||
is_private: repoForm.is_private
|
||||
});
|
||||
onClose();
|
||||
navigate(`/${projectName}/repo/${repoForm.repo_name.trim()}`);
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || t("project_create.create_failed_repo"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChannel = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!channelForm.room_name.trim()) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
let category: string | null = channelForm.category === "none" ? null : channelForm.category;
|
||||
if (channelForm.category === "new") {
|
||||
if (!channelForm.new_category.trim()) {
|
||||
setError(t("project_create.enter_group_name"));
|
||||
return;
|
||||
}
|
||||
const createdCategory = await createCategory.mutateAsync({
|
||||
name: channelForm.new_category.trim(),
|
||||
position: categories.length,
|
||||
});
|
||||
category = createdCategory?.id ?? null;
|
||||
}
|
||||
const room = await createRoom.mutateAsync({
|
||||
room_name: channelForm.room_name.trim(),
|
||||
room_public: channelForm.is_public,
|
||||
category,
|
||||
});
|
||||
onClose();
|
||||
if (room) {
|
||||
navigate(`/${projectName}/channel/${room.id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || t("project_create.create_failed_channel"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBoard = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!boardForm.name.trim()) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await boardOps.createBoard.mutateAsync({
|
||||
name: boardForm.name.trim(),
|
||||
description: boardForm.description.trim() || undefined
|
||||
});
|
||||
onClose();
|
||||
if (res.data?.data) {
|
||||
navigate(`/${projectName}/board/${res.data.data.id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || t("project_create.create_failed_board"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSkill = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!skillForm.name.trim()) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const skill = await createSkill.mutateAsync({
|
||||
slug: skillForm.name.trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
name: skillForm.name.trim(),
|
||||
description: skillForm.description.trim() || undefined,
|
||||
content: "# New AI Skill\n\nExplain how this skill works here."
|
||||
});
|
||||
onClose();
|
||||
if (skill) {
|
||||
navigate(`/${projectName}/skills/${skill.slug}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || t("project_create.create_failed_skill"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!projectName || !inviteForm.email.trim()) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await projectInviteUser(projectName, {
|
||||
email: inviteForm.email.trim(),
|
||||
scope: inviteForm.scope,
|
||||
});
|
||||
setInviteForm({ email: "", scope: "Member" });
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || t("project_create.create_failed_invite"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div
|
||||
className="border rounded-2xl shadow-2xl w-full max-w-[540px] overflow-hidden animate-in zoom-in-95 duration-200"
|
||||
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header Tabs */}
|
||||
<div className="px-1 pt-1 border-b" style={{ backgroundColor: "var(--surface-rail)", borderColor: "var(--border-default)" }}>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="text-[14px] font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<PlusCircle className="w-4 h-4" style={{ color: "var(--accent)" }} /> {t("project_create.quick_start")}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex px-2">
|
||||
{[
|
||||
{ id: "repo", label: t("project_create.repo_tab"), icon: FolderPlus },
|
||||
{ id: "channel", label: t("project_create.channel_tab"), icon: MessageSquarePlus },
|
||||
{ id: "board", label: t("project_create.board_tab"), icon: Kanban },
|
||||
{ id: "skill", label: t("project_create.skill_tab"), icon: Zap },
|
||||
{ id: "invite", label: t("project_create.invite_tab"), icon: UserPlus },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => { setActiveTab(tab.id as typeof activeTab); setError(null); }}
|
||||
className="flex-1 py-2.5 text-[12px] font-bold transition-all border-b-2 flex items-center justify-center gap-2"
|
||||
style={{
|
||||
borderColor: activeTab === tab.id ? "var(--accent)" : "transparent",
|
||||
color: activeTab === tab.id ? "var(--text-primary)" : "var(--text-secondary)"
|
||||
}}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === "repo" && (
|
||||
<form onSubmit={handleCreateRepo} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.repo_name")}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("project_create.repo_name_placeholder")}
|
||||
value={repoForm.repo_name}
|
||||
onChange={e => setRepoForm({...repoForm, repo_name: e.target.value.toLowerCase().replace(/\s+/g, '-')})}
|
||||
className="border-none h-10 text-[14px] font-mono"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.description_optional")}</Label>
|
||||
<Input
|
||||
placeholder={t("project_create.repo_desc_placeholder")}
|
||||
value={repoForm.description}
|
||||
onChange={e => setRepoForm({...repoForm, description: e.target.value})}
|
||||
className="border-none h-10 text-[14px]"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
style={{ backgroundColor: "var(--surface-ground)", borderColor: "var(--border-default)" }}
|
||||
onClick={() => setRepoForm({...repoForm, is_private: !repoForm.is_private})}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{repoForm.is_private ? <Lock className="w-4 h-4" style={{ color: "var(--warning)" }} /> : <Globe className="w-4 h-4" style={{ color: "var(--success)" }} />}
|
||||
<div>
|
||||
<p className="text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>{repoForm.is_private ? t("project.repos.private") : t("project.repos.public")}</p>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{repoForm.is_private ? t("project_create.private_desc") : t("project_create.public_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-8 h-4 rounded-full relative transition-colors" style={{ backgroundColor: repoForm.is_private ? "var(--accent)" : "var(--border-default)" }}>
|
||||
<div className={`absolute top-0.5 w-3 h-3 rounded-full transition-all ${repoForm.is_private ? 'right-0.5' : 'left-0.5'}`} style={{ backgroundColor: "var(--text-inverse)" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col gap-3">
|
||||
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!repoForm.repo_name.trim() || loading}
|
||||
className="w-full font-bold h-10"
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <FolderPlus className="w-4 h-4 mr-2" />}
|
||||
{t("project_create.create_repo")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === "channel" && (
|
||||
<form onSubmit={handleCreateChannel} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.channel_name")}</Label>
|
||||
<div className="relative">
|
||||
<Hash className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: "var(--text-muted)" }} />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("project_create.channel_name_placeholder")}
|
||||
value={channelForm.room_name}
|
||||
onChange={e => setChannelForm({...channelForm, room_name: e.target.value.toLowerCase().replace(/\s+/g, '-')})}
|
||||
className="border-none h-10 pl-9 text-[14px]"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
style={{ backgroundColor: "var(--surface-ground)", borderColor: "var(--border-default)" }}
|
||||
onClick={() => setChannelForm({...channelForm, is_public: !channelForm.is_public})}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{channelForm.is_public ? <Globe className="w-4 h-4" style={{ color: "var(--success)" }} /> : <Lock className="w-4 h-4" style={{ color: "var(--warning)" }} />}
|
||||
<div>
|
||||
<p className="text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>{channelForm.is_public ? t("project_create.public_channel") : t("project_create.private_channel")}</p>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>{channelForm.is_public ? t("project_create.public_channel_desc") : t("project_create.private_channel_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-8 h-4 rounded-full relative transition-colors" style={{ backgroundColor: channelForm.is_public ? "var(--success)" : "var(--border-default)" }}>
|
||||
<div className={`absolute top-0.5 w-3 h-3 rounded-full transition-all ${channelForm.is_public ? 'right-0.5' : 'left-0.5'}`} style={{ backgroundColor: "var(--text-inverse)" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.group")}</Label>
|
||||
<Select
|
||||
value={channelForm.category}
|
||||
onValueChange={(value) => setChannelForm({...channelForm, category: value})}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-none" style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">{t("project_create.no_group")}</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>{category.name === "default" ? "Channels" : category.name}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="new">{t("project_create.create_new_group")}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{channelForm.category === "new" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.group_name")}</Label>
|
||||
<Input
|
||||
placeholder={t("project_create.group_name_placeholder")}
|
||||
value={channelForm.new_category}
|
||||
onChange={e => setChannelForm({...channelForm, new_category: e.target.value})}
|
||||
className="border-none h-10 text-[14px]"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 flex flex-col gap-3">
|
||||
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!channelForm.room_name.trim() || loading}
|
||||
className="w-full font-bold h-10"
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <MessageSquarePlus className="w-4 h-4 mr-2" />}
|
||||
{t("project_create.create_channel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === "board" && (
|
||||
<form onSubmit={handleCreateBoard} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.board_name")}</Label>
|
||||
<div className="relative">
|
||||
<Kanban className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: "var(--text-muted)" }} />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("project_create.board_name_placeholder")}
|
||||
value={boardForm.name}
|
||||
onChange={e => setBoardForm({...boardForm, name: e.target.value})}
|
||||
className="border-none h-10 pl-9 text-[14px]"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.description_optional")}</Label>
|
||||
<Textarea
|
||||
placeholder={t("project_create.board_desc_placeholder")}
|
||||
value={boardForm.description}
|
||||
onChange={e => setBoardForm({...boardForm, description: e.target.value})}
|
||||
className="border-none min-h-[100px] text-[14px] resize-none py-3"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col gap-3">
|
||||
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!boardForm.name.trim() || loading}
|
||||
className="w-full font-bold h-10"
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
{t("project_create.create_board")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === "skill" && (
|
||||
<form onSubmit={handleCreateSkill} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.skill_name")}</Label>
|
||||
<div className="relative">
|
||||
<Zap className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: "var(--text-muted)" }} />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("project_create.skill_name_placeholder")}
|
||||
value={skillForm.name}
|
||||
onChange={e => setSkillForm({...skillForm, name: e.target.value})}
|
||||
className="border-none h-10 pl-9 text-[14px]"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.description_optional")}</Label>
|
||||
<Textarea
|
||||
placeholder={t("project_create.skill_desc_placeholder")}
|
||||
value={skillForm.description}
|
||||
onChange={e => setSkillForm({...skillForm, description: e.target.value})}
|
||||
className="border-none min-h-[100px] text-[14px] resize-none py-3"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col gap-3">
|
||||
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!skillForm.name.trim() || loading}
|
||||
className="w-full font-bold h-10"
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
{t("project_create.create_skill")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === "invite" && (
|
||||
<form onSubmit={handleInviteUser} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.invite_email")}</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: "var(--text-muted)" }} />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("project_create.email_placeholder")}
|
||||
value={inviteForm.email}
|
||||
onChange={e => setInviteForm({...inviteForm, email: e.target.value})}
|
||||
className="border-none h-10 pl-9 text-[14px]"
|
||||
style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] font-bold uppercase tracking-widest" style={{ color: "var(--text-muted)" }}>{t("project_create.role")}</Label>
|
||||
<Select
|
||||
value={inviteForm.scope}
|
||||
onValueChange={(value) => setInviteForm({...inviteForm, scope: value as MemberRole})}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-none" style={{ backgroundColor: "var(--surface-ground)", color: "var(--text-primary)" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="Member">{t("project_create.member_role")}</SelectItem>
|
||||
<SelectItem value="Admin">{t("project_create.admin_role")}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col gap-3">
|
||||
{error && <p className="text-[12px] font-medium p-2 rounded border" style={{ color: "var(--destructive)", backgroundColor: "var(--destructive-alpha10)", borderColor: "var(--destructive)" }}>{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!inviteForm.email.trim() || loading}
|
||||
className="w-full font-bold h-10"
|
||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <UserPlus className="w-4 h-4 mr-2" />}
|
||||
{t("project_create.send_invitation")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,482 +0,0 @@
|
||||
import {
|
||||
useIssueAssigneesQuery,
|
||||
useAddAssigneeMutation,
|
||||
useRemoveAssigneeMutation,
|
||||
useIssueLabelsQuery,
|
||||
useAddIssueLabelMutation,
|
||||
useRemoveIssueLabelMutation,
|
||||
useProjectLabelsQuery,
|
||||
useIssueSubscribersQuery,
|
||||
useIssueSubscribeMutation,
|
||||
useIssueUnsubscribeMutation,
|
||||
useIssuePRsQuery,
|
||||
useIssueReposQuery,
|
||||
useLinkPRMutation,
|
||||
useUnlinkPRMutation,
|
||||
useLinkRepoMutation,
|
||||
useUnlinkRepoMutation,
|
||||
} from "@/hooks/useIssueExtraQuery"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Tag,
|
||||
Bell,
|
||||
GitPullRequest,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Plus,
|
||||
X,
|
||||
Users,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { t } from "@/i18n/T"
|
||||
|
||||
interface IssueSidebarProps {
|
||||
projectName: string
|
||||
issueNumber: number
|
||||
}
|
||||
|
||||
export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
|
||||
const { data: assignees = [] } = useIssueAssigneesQuery({
|
||||
projectName,
|
||||
issueNumber,
|
||||
})
|
||||
const { data: issueLabels = [] } = useIssueLabelsQuery({
|
||||
projectName,
|
||||
issueNumber,
|
||||
})
|
||||
const { data: projectLabels = [] } = useProjectLabelsQuery(projectName)
|
||||
const { data: subscribers = [] } = useIssueSubscribersQuery({
|
||||
projectName,
|
||||
issueNumber,
|
||||
})
|
||||
const { data: prs = [] } = useIssuePRsQuery({ projectName, issueNumber })
|
||||
const { data: repos = [] } = useIssueReposQuery({ projectName, issueNumber })
|
||||
|
||||
const addAssignee = useAddAssigneeMutation()
|
||||
const removeAssignee = useRemoveAssigneeMutation()
|
||||
const addLabel = useAddIssueLabelMutation()
|
||||
const removeLabel = useRemoveIssueLabelMutation()
|
||||
const subscribe = useIssueSubscribeMutation()
|
||||
const unsubscribe = useIssueUnsubscribeMutation()
|
||||
const linkPR = useLinkPRMutation()
|
||||
const unlinkPR = useUnlinkPRMutation()
|
||||
const linkRepo = useLinkRepoMutation()
|
||||
const unlinkRepo = useUnlinkRepoMutation()
|
||||
|
||||
const isSubscribed = subscribers.some((s) => s.user_id === "me")
|
||||
|
||||
const [showLinkPR, setShowLinkPR] = useState(false)
|
||||
const [prRepo, setPrRepo] = useState("")
|
||||
const [prNum, setPrNum] = useState("")
|
||||
const [showLinkRepo, setShowLinkRepo] = useState(false)
|
||||
const [repoName, setRepoName] = useState("")
|
||||
|
||||
return (
|
||||
<div className="space-y-5 rounded-2xl border border-border/70 bg-card/80 p-4 shadow-sm backdrop-blur">
|
||||
{/* Assignees */}
|
||||
<div className="space-y-2">
|
||||
<div className="group flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
<Users className="size-3.5" /> {t("project.issue_detail.assignees")}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{assignees.length}
|
||||
</Badge>
|
||||
</h3>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="text-[10px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100 hover:underline">
|
||||
{t("project.issue_detail.edit")}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem className="text-xs">
|
||||
{t("project.issue_detail.assign_to_me")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<p className="px-2 py-1.5 text-[10px] font-bold text-muted-foreground uppercase">
|
||||
{t("project.issue_detail.suggestions")}
|
||||
</p>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addAssignee.mutate({ projectName, issueNumber, userId: "me" })
|
||||
}
|
||||
>
|
||||
admin
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="space-y-1.5 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 p-3">
|
||||
{assignees.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("project.issue_detail.no_assigned")}
|
||||
</p>
|
||||
) : (
|
||||
assignees.map((a) => (
|
||||
<div
|
||||
key={a.username}
|
||||
className="group flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Avatar className="size-5 rounded-full">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{a.username[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate text-foreground">
|
||||
{a.username}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
removeAssignee.mutate({
|
||||
projectName,
|
||||
issueNumber,
|
||||
userId: a.user_id,
|
||||
})
|
||||
}
|
||||
className="text-destructive opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Labels */}
|
||||
<div className="space-y-2">
|
||||
<div className="group flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
<Tag className="size-3.5" /> {t("project.issue_detail.labels")}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{issueLabels.length}
|
||||
</Badge>
|
||||
</h3>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="text-[10px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100 hover:underline">
|
||||
{t("project.issue_detail.edit")}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<p className="px-2 py-1.5 text-[10px] font-bold text-muted-foreground uppercase">
|
||||
{t("project.issue_detail.apply_labels")}
|
||||
</p>
|
||||
{projectLabels.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() =>
|
||||
addLabel.mutate({ projectName, issueNumber, labelId: l.id })
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="size-2.5 rounded-full"
|
||||
style={{ backgroundColor: l.color || "var(--accent)" }}
|
||||
/>
|
||||
<span className="flex-1">{l.name}</span>
|
||||
{issueLabels.some((il) => il.label_name === l.name) && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-accent">
|
||||
<Settings className="mr-2" /> Manage labels
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 p-3">
|
||||
{issueLabels.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("project.issue_detail.none_yet")}
|
||||
</p>
|
||||
) : (
|
||||
issueLabels.map((l) => (
|
||||
<Badge
|
||||
key={l.label_name}
|
||||
variant="outline"
|
||||
className="h-5 border-border/70 bg-muted/40 px-1.5 text-[10px] text-foreground"
|
||||
style={{
|
||||
borderLeft: `3px solid ${l.label_color ?? "var(--accent)"}`,
|
||||
}}
|
||||
>
|
||||
{l.label_name}
|
||||
<button
|
||||
onClick={() =>
|
||||
removeLabel.mutate({
|
||||
projectName,
|
||||
issueNumber,
|
||||
labelId: l.label_id,
|
||||
})
|
||||
}
|
||||
className="ml-1 transition-colors hover:text-destructive"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
<Bell className="size-3.5" />{" "}
|
||||
{t("project.issue_detail.notifications")}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{subscribers.length}
|
||||
</Badge>
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start rounded-full text-xs"
|
||||
onClick={() => {
|
||||
if (isSubscribed) {
|
||||
unsubscribe.mutate({ projectName, issueNumber })
|
||||
} else {
|
||||
subscribe.mutate({ projectName, issueNumber })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSubscribed
|
||||
? t("project.issue_detail.unsubscribe")
|
||||
: t("project.issue_detail.subscribe")}
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("project.issue_detail.subscribers_count", {
|
||||
count: String(subscribers.length),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Development */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
|
||||
{t("project.issue_detail.development")}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{prs.length + repos.length}
|
||||
</Badge>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{prs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase">
|
||||
{t("project.issue_detail.pull_requests")}
|
||||
</p>
|
||||
{prs.map((pr) => (
|
||||
<div
|
||||
key={`${pr.repo}-${pr.number}`}
|
||||
className="group flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-1.5 text-accent hover:underline">
|
||||
<GitPullRequest className="size-3" />
|
||||
<span>
|
||||
#{pr.number} in {pr.repo}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
unlinkPR.mutate({
|
||||
projectName,
|
||||
issueNumber,
|
||||
repo: pr.repo!,
|
||||
prNumber: pr.number!,
|
||||
})
|
||||
}
|
||||
className="text-destructive opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{repos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase">
|
||||
{t("project.issue_detail.linked_repos")}
|
||||
</p>
|
||||
{repos.map((r) => (
|
||||
<div
|
||||
key={r.repo}
|
||||
className="group flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-1.5 text-accent hover:underline">
|
||||
<LinkIcon className="size-3" />
|
||||
<span>{r.repo}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
unlinkRepo.mutate({
|
||||
projectName,
|
||||
issueNumber,
|
||||
repoId: r.repo!,
|
||||
})
|
||||
}
|
||||
className="text-destructive opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 px-3 py-1.5 text-[10px] font-medium text-accent transition-colors hover:bg-[var(--hover-bg)]"
|
||||
onClick={() => setShowLinkPR(true)}
|
||||
>
|
||||
<Plus className="size-3" /> {t("project.issue_detail.link_pr")}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 px-3 py-1.5 text-[10px] font-medium text-accent transition-colors hover:bg-[var(--hover-bg)]"
|
||||
onClick={() => setShowLinkRepo(true)}
|
||||
>
|
||||
<Plus className="size-3" /> {t("project.issue_detail.link_repo")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link PR Dialog */}
|
||||
<Dialog open={showLinkPR} onOpenChange={setShowLinkPR}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("project.issue_detail.link_pr_title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
Repo
|
||||
</label>
|
||||
<Input
|
||||
value={prRepo}
|
||||
onChange={(e) => setPrRepo(e.target.value)}
|
||||
placeholder="owner/repo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
PR Number
|
||||
</label>
|
||||
<Input
|
||||
value={prNum}
|
||||
onChange={(e) => setPrNum(e.target.value)}
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
style={{ backgroundColor: "var(--accent)" }}
|
||||
onClick={() => {
|
||||
const parts = prRepo.split("/")
|
||||
if (parts.length === 2 && prNum) {
|
||||
linkPR.mutate({
|
||||
projectName,
|
||||
issueNumber,
|
||||
repo: prRepo,
|
||||
prNumber: Number(prNum),
|
||||
})
|
||||
setShowLinkPR(false)
|
||||
setPrRepo("")
|
||||
setPrNum("")
|
||||
}
|
||||
}}
|
||||
disabled={linkPR.isPending}
|
||||
>
|
||||
Link
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Link Repo Dialog */}
|
||||
<Dialog open={showLinkRepo} onOpenChange={setShowLinkRepo}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("project.issue_detail.link_repo_title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
Repo
|
||||
</label>
|
||||
<Input
|
||||
value={repoName}
|
||||
onChange={(e) => setRepoName(e.target.value)}
|
||||
placeholder="owner/repo"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
style={{ backgroundColor: "var(--accent)" }}
|
||||
onClick={() => {
|
||||
if (repoName) {
|
||||
linkRepo.mutate({ projectName, issueNumber, repo: repoName })
|
||||
setShowLinkRepo(false)
|
||||
setRepoName("")
|
||||
}
|
||||
}}
|
||||
disabled={linkRepo.isPending}
|
||||
>
|
||||
Link
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useIssueReactionsQuery,
|
||||
} from "@/hooks/useIssueExtraQuery";
|
||||
import { issueReactionAdd, issueReactionRemove } from "@/client/api";
|
||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ReactionBarProps {
|
||||
projectName: string;
|
||||
issueNumber: number;
|
||||
}
|
||||
|
||||
const EMOJI_MAP: Record<string, string> = {
|
||||
plus_one: "👍",
|
||||
minus_one: "👎",
|
||||
laugh: "😄",
|
||||
hooray: "🎉",
|
||||
confused: "😕",
|
||||
heart: "❤️",
|
||||
rocket: "🚀",
|
||||
eyes: "👀",
|
||||
};
|
||||
|
||||
export function ReactionBar({ projectName, issueNumber }: ReactionBarProps) {
|
||||
const { data: currentUser } = useCurrentUserQuery();
|
||||
const { data: reactions } = useIssueReactionsQuery({ projectName, issueNumber });
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleToggleReaction = async (reaction: string) => {
|
||||
const existing = reactions?.reactions.find(r =>
|
||||
r.reaction === reaction &&
|
||||
r.users.includes(currentUser?.uid ?? "me")
|
||||
);
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
await issueReactionRemove(projectName, issueNumber, reaction);
|
||||
} else {
|
||||
await issueReactionAdd(projectName, issueNumber, { reaction });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["issue", projectName, issueNumber, "reactions"] });
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle reaction", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!reactions) return null;
|
||||
|
||||
const displayed = reactions.reactions.filter(r => r.count > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
{displayed.map((r) => (
|
||||
<button
|
||||
key={r.reaction}
|
||||
onClick={() => handleToggleReaction(r.reaction)}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border transition-colors ${
|
||||
r.users.includes(currentUser?.uid ?? "me")
|
||||
? "border-[var(--accent)]"
|
||||
: "border-transparent hover:border-[var(--border-default)]"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: r.users.includes(currentUser?.uid ?? "me") ? "rgba(var(--accent-rgb, 88, 101, 242), 0.1)" : "var(--surface-elevated)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
<span>{EMOJI_MAP[r.reaction] || r.reaction}</span>
|
||||
<span>{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full w-7 h-7"
|
||||
style={{ backgroundColor: "var(--surface-elevated)" }}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-1" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }} side="top" align="start">
|
||||
<div className="flex gap-1">
|
||||
{Object.entries(EMOJI_MAP).map(([name, emoji]) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleToggleReaction(name)}
|
||||
className="p-1.5 hover:bg-[var(--surface-elevated)] rounded transition-colors text-lg"
|
||||
style={{ borderColor: "var(--border-default)" }}
|
||||
title={name}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useCreateSkillMutation } from "@/hooks/useSkillsQuery";
|
||||
|
||||
interface CreateSkillDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateSkillDialog({ open, onOpenChange }: CreateSkillDialogProps) {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const { mutate: createSkill, isPending } = useCreateSkillMutation(projectName);
|
||||
|
||||
const [slug, setSlug] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setSlug("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
setContent("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) reset();
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!slug.trim()) {
|
||||
setError("Slug is required");
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError("Content is required");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
createSkill(
|
||||
{
|
||||
slug: slug.trim(),
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
content: content.trim(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => handleOpenChange(false),
|
||||
onError: (err) =>
|
||||
setError(err instanceof Error ? err.message : "Create failed"),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new AI skill to the project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="slug">Slug *</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="my-skill"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Skill"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What this skill does"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="content">Content *</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Skill content or prompt..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useDeleteSkillMutation } from "@/hooks/useSkillsQuery";
|
||||
import type { SkillResponse } from "@/client/model";
|
||||
|
||||
interface DeleteSkillDialogProps {
|
||||
skill: SkillResponse;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function DeleteSkillDialog({ skill, open, onOpenChange, onDeleted }: DeleteSkillDialogProps) {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const { mutate: deleteSkill, isPending } = useDeleteSkillMutation(projectName);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = () => {
|
||||
setError(null);
|
||||
deleteSkill(skill.slug, {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete skill</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{skill.name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user