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