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:
zhenyi 2026-05-20 13:39:18 +08:00
parent f6f69a063e
commit 2b543f5e37
12 changed files with 0 additions and 2909 deletions

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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>
)}
</>
);
}

View File

@ -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>
)}
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}