feat(frontend): update UI components, skill pages, and hooks
Refactor ChatMessageList, ChannelSidebar, and skill detail/pages. Add CreateSkillDialog and DeleteSkillDialog. Update MarkdownRenderer, use-mobile hook, and useSkillsQuery.
This commit is contained in:
parent
de85417053
commit
ac9ffb2a7a
@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ServerIconRail } from "@/components/layout/ServerIconRail";
|
||||
@ -26,8 +26,18 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
|
||||
const isTablet = useIsTablet();
|
||||
const canShowMembers = !isMobile && !isTablet;
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ showMembers, setShowMembers }),
|
||||
[showMembers],
|
||||
);
|
||||
|
||||
const toggleSidebar = useCallback(
|
||||
() => setIsSidebarCollapsed((v) => !v),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChannelContext.Provider value={{ showMembers, setShowMembers }}>
|
||||
<ChannelContext.Provider value={contextValue}>
|
||||
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
{!isMobile && <ServerIconRail />}
|
||||
|
||||
@ -44,7 +54,7 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
|
||||
{!isSidebarCollapsed && <ChannelSidebar />}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
onClick={toggleSidebar}
|
||||
className="absolute flex items-center justify-center w-5 h-10 cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Copy, Check, Sparkles, ClipboardList, Pencil, RefreshCw, GitFork } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { memo, useState } from "react";
|
||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||
import { useEditMessageMutation, useMessageVersionsQuery, useSwitchMessageVersionMutation } from "@/hooks/useAiChatQuery";
|
||||
import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer";
|
||||
@ -136,7 +136,7 @@ function ThinkingSection({ content, isOpen, onToggle }: { content: string; isOpe
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessageBubble({ message, conversationId, onRegenerate }: ChatMessageBubbleProps) {
|
||||
export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate }: ChatMessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const [copied, setCopied] = useState<"answer" | "full" | false>(false);
|
||||
const [openThinking, setOpenThinking] = useState<Record<number, boolean>>({});
|
||||
@ -472,4 +472,4 @@ export function ChatMessageBubble({ message, conversationId, onRegenerate }: Cha
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Loader2, Code, FileText, GitPullRequest, Brain, ChevronDown } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@ -78,24 +78,13 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
if (!atBottom) setUserScrolledUp(true);
|
||||
}, []);
|
||||
|
||||
// Streaming bubble (always counted for virtualization)
|
||||
// Streaming bubble — rendered OUTSIDE the virtualizer to avoid
|
||||
// recalculating positions on every token. The virtualizer only
|
||||
// handles stable, persisted messages.
|
||||
const hasStreamingBubble = !!stream?.parts && stream.parts.length > 0;
|
||||
const totalItems = messages.length + (hasStreamingBubble ? 1 : 0);
|
||||
|
||||
// Build item data array: messages + optional streaming bubble
|
||||
const items = useMemo(() => {
|
||||
const result: Array<{ type: "message"; message: typeof messages[0] } | { type: "streaming" }> = [];
|
||||
for (const m of messages) {
|
||||
result.push({ type: "message", message: m });
|
||||
}
|
||||
if (hasStreamingBubble) {
|
||||
result.push({ type: "streaming" });
|
||||
}
|
||||
return result;
|
||||
}, [messages, hasStreamingBubble]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: totalItems,
|
||||
count: messages.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ESTIMATED_SIZE,
|
||||
overscan: OVERSCAN,
|
||||
@ -220,7 +209,7 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtualized message list */}
|
||||
{/* Virtualized message list — persisted messages only */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
@ -232,8 +221,8 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const item = items[virtualItem.index];
|
||||
if (!item) return null;
|
||||
const message = messages[virtualItem.index];
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -245,22 +234,27 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{item.type === "message" ? (
|
||||
<ChatMessageBubble
|
||||
message={item.message}
|
||||
conversationId={conversationId}
|
||||
onRegenerate={(_newMsgId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<StreamingBubble parts={stream!.parts} isDone={stream!.isDone} />
|
||||
)}
|
||||
<ChatMessageBubble
|
||||
message={message}
|
||||
conversationId={conversationId}
|
||||
onRegenerate={(_newMsgId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-messages", conversationId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-conversations", conversationId] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Streaming bubble — outside virtualizer so it doesn't trigger
|
||||
position recalculations on every token. Uses a simple content-key
|
||||
to help React skip unchanged MarkdownRenderer re-parsing. */}
|
||||
{hasStreamingBubble && (
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<StreamingBubble parts={stream!.parts} isDone={stream!.isDone} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -268,8 +262,41 @@ export function ChatMessageList({ conversationId }: ChatMessageListProps) {
|
||||
|
||||
function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boolean }) {
|
||||
const [openThinking, setOpenThinking] = useState<Record<number, boolean>>({});
|
||||
// Display state synced at animation-frame rate so ReactMarkdown only
|
||||
// re-parses at ~60fps, not on every token from the SSE stream.
|
||||
const [displayParts, setDisplayParts] = useState<StreamPart[]>([]);
|
||||
const [displayDone, setDisplayDone] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const latestRef = useRef({ parts, isDone });
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
latestRef.current = { parts, isDone };
|
||||
|
||||
// Start rAF sync loop when streaming begins
|
||||
const hasParts = parts.length > 0;
|
||||
useEffect(() => {
|
||||
if (!hasParts) return;
|
||||
const sync = () => {
|
||||
const { parts: p, isDone: d } = latestRef.current;
|
||||
setDisplayParts([...p]);
|
||||
setDisplayDone(d);
|
||||
if (!d) {
|
||||
rafRef.current = requestAnimationFrame(sync);
|
||||
}
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(sync);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [hasParts]);
|
||||
|
||||
// Final sync when streaming stops (captures last frame)
|
||||
useEffect(() => {
|
||||
if (isDone) {
|
||||
setDisplayParts([...parts]);
|
||||
setDisplayDone(true);
|
||||
}
|
||||
}, [isDone, parts]);
|
||||
|
||||
// Reset height for virtualizer measurement
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.height = "auto";
|
||||
@ -279,13 +306,13 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [parts.length]);
|
||||
}, [displayParts.length]);
|
||||
|
||||
const toggleThinking = (idx: number) => {
|
||||
setOpenThinking((prev) => ({ ...prev, [idx]: !prev[idx] }));
|
||||
};
|
||||
|
||||
const firstThinkingIdx = parts.findIndex((p) => p.type === "thinking");
|
||||
const firstThinkingIdx = displayParts.findIndex((p) => p.type === "thinking");
|
||||
|
||||
return (
|
||||
<div ref={contentRef} className="flex gap-4 px-4 py-3 max-w-3xl mx-auto w-full">
|
||||
@ -305,22 +332,23 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
<span className="text-xs font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
Assistant
|
||||
</span>
|
||||
{!isDone && (
|
||||
{!displayDone && (
|
||||
<span className="text-[11px] animate-pulse" style={{ color: "var(--text-muted)" }}>
|
||||
responding…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interleaved rendering — thinking (collapsible) + token in order */}
|
||||
{/* Interleaved rendering — thinking (collapsible) + token in order.
|
||||
Rendered from displayParts which sync at ~60fps via rAF.
|
||||
rehype-raw + rehype-sanitize allow safe inline HTML. */}
|
||||
<div className="text-sm" style={{ color: "var(--text-primary)" }}>
|
||||
{parts.map((part, i) => {
|
||||
{displayParts.map((part, i) => {
|
||||
if (part.type === "thinking") {
|
||||
const isOpen = openThinking[i] ?? false;
|
||||
const isActivelyThinking = !isDone && i === firstThinkingIdx;
|
||||
const isActivelyThinking = !displayDone && i === firstThinkingIdx;
|
||||
return (
|
||||
<div key={i} className="mb-3" style={{ whiteSpace: "normal" }}>
|
||||
{/* DeepSeek-style horizontal separator */}
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 cursor-pointer select-none"
|
||||
onClick={() => toggleThinking(i)}
|
||||
@ -342,7 +370,7 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="mt-1 p-3 rounded-xl text-sm leading-relaxed max-h-[400px] overflow-y-auto"
|
||||
className="mt-1 p-3 rounded-xl text-sm leading-relaxed max-h-[400px] overflow-y-auto whitespace-pre-wrap"
|
||||
style={{
|
||||
backgroundColor: "var(--surface-elevated)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
@ -356,16 +384,35 @@ function StreamingBubble({ parts, isDone }: { parts: StreamPart[]; isDone: boole
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Token content — rendered as full Markdown + safe HTML.
|
||||
// MarkdownRenderer is memoized so only re-renders when content changes
|
||||
// (which happens at rAF rate, not per SSE token).
|
||||
const isLast = i === displayParts.length - 1;
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
key={i}
|
||||
content={part.content}
|
||||
className="prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1"
|
||||
/>
|
||||
<div key={i}>
|
||||
<MarkdownRenderer
|
||||
content={part.content}
|
||||
className="prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1"
|
||||
/>
|
||||
{isLast && !displayDone && <StreamingCursor />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Blinking cursor for typing feel during streaming. */
|
||||
function StreamingCursor() {
|
||||
return (
|
||||
<span
|
||||
className="inline-block w-[2px] h-[1.1em] ml-[1px] align-text-bottom animate-pulse rounded-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
animationDuration: "0.8s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,22 +1,78 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useSkillDetailQuery } from "@/hooks/useSkillDetailQuery";
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useSkillDetailQuery, useUpdateSkillMutation } from "@/hooks/useSkillsQuery";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
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 { LoadingState } from "@/components/ui/LoadingState";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { Lightbulb, Calendar, Code } from "lucide-react";
|
||||
import { Lightbulb, Calendar, Code, Pencil, Trash2 } from "lucide-react";
|
||||
import { DeleteSkillDialog } from "@/app/project/skills/DeleteSkillDialog";
|
||||
|
||||
export function SkillDetailPage() {
|
||||
const { projectName, skillSlug } = useParams<{
|
||||
projectName: string;
|
||||
skillSlug: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
data: skillDetail,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useSkillDetailQuery({ projectName: projectName!, slug: skillSlug! });
|
||||
} = useSkillDetailQuery(projectName, skillSlug);
|
||||
|
||||
const { mutate: updateSkill, isPending: isSaving } = useUpdateSkillMutation(projectName);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [editEnabled, setEditEnabled] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (!skillDetail) return;
|
||||
setEditName(skillDetail.name);
|
||||
setEditDescription(skillDetail.description ?? "");
|
||||
setEditContent(skillDetail.content);
|
||||
setEditEnabled(skillDetail.enabled);
|
||||
setSaveError(null);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!projectName || !skillSlug) return;
|
||||
setSaveError(null);
|
||||
updateSkill(
|
||||
{
|
||||
slug: skillSlug,
|
||||
req: {
|
||||
name: editName || undefined,
|
||||
description: editDescription || undefined,
|
||||
content: editContent,
|
||||
enabled: editEnabled,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
setSaveError(err instanceof Error ? err.message : "Save failed"),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
refetch();
|
||||
@ -45,10 +101,45 @@ export function SkillDetailPage() {
|
||||
return (
|
||||
<div className="p-6 h-full overflow-auto">
|
||||
<PageHeader
|
||||
title={skillDetail.name}
|
||||
description={skillDetail.description || "Skill details"}
|
||||
title={editing ? "Edit skill" : skillDetail.name}
|
||||
description={
|
||||
editing ? "Modify skill details" : (skillDetail.description || "Skill details")
|
||||
}
|
||||
actions={
|
||||
editing ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleCancelEdit} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleStartEdit}>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<div className="mb-4 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-card rounded-lg border border-border p-4" style={{ backgroundColor: "var(--surface-elevated)" }}>
|
||||
@ -56,9 +147,17 @@ export function SkillDetailPage() {
|
||||
<Code className="w-4 h-4" />
|
||||
Skill Content
|
||||
</div>
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono rounded p-3 overflow-auto max-h-[600px]" style={{ backgroundColor: "var(--hover-bg)", color: "var(--text-primary)" }}>
|
||||
{skillDetail.content}
|
||||
</pre>
|
||||
{editing ? (
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="font-mono text-sm min-h-[400px]"
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono rounded p-3 overflow-auto max-h-[600px]" style={{ backgroundColor: "var(--hover-bg)", color: "var(--text-primary)" }}>
|
||||
{skillDetail.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -73,26 +172,75 @@ export function SkillDetailPage() {
|
||||
<dt style={{ color: "var(--text-muted)" }}>Slug</dt>
|
||||
<dd className="font-mono" style={{ color: "var(--text-primary)" }}>{skillDetail.slug}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt style={{ color: "var(--text-muted)" }}>Status</dt>
|
||||
<dd style={{ color: "var(--text-primary)" }}>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs"
|
||||
style={{
|
||||
backgroundColor: skillDetail.enabled ? "var(--success-alpha10)" : "var(--hover-bg)",
|
||||
color: skillDetail.enabled ? "var(--success)" : "var(--text-muted)"
|
||||
}}
|
||||
>
|
||||
{skillDetail.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
{skillDetail.created_at && (
|
||||
<div className="flex justify-between">
|
||||
<dt style={{ color: "var(--text-muted)" }}>Created</dt>
|
||||
<dd className="flex items-center gap-1" style={{ color: "var(--text-primary)" }}>
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(skillDetail.created_at).toLocaleDateString()}
|
||||
{editing ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<Label htmlFor="edit-name" style={{ color: "var(--text-muted)" }}>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<Label htmlFor="edit-desc" style={{ color: "var(--text-muted)" }}>
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-desc"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<dt style={{ color: "var(--text-muted)" }}>Status</dt>
|
||||
<dd style={{ color: "var(--text-primary)" }}>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs"
|
||||
style={{
|
||||
backgroundColor: skillDetail.enabled ? "var(--success-alpha10)" : "var(--hover-bg)",
|
||||
color: skillDetail.enabled ? "var(--success)" : "var(--text-muted)"
|
||||
}}
|
||||
>
|
||||
{skillDetail.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
{skillDetail.created_at && (
|
||||
<div className="flex justify-between">
|
||||
<dt style={{ color: "var(--text-muted)" }}>Created</dt>
|
||||
<dd className="flex items-center gap-1" style={{ color: "var(--text-primary)" }}>
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(skillDetail.created_at).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editing && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<dt style={{ color: "var(--text-muted)" }}>Enabled</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditEnabled(!editEnabled)}
|
||||
className={`px-2 py-0.5 rounded-full text-xs transition-colors ${
|
||||
editEnabled
|
||||
? "bg-success-alpha10 text-success"
|
||||
: "bg-hover-bg text-muted-foreground"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: editEnabled ? "var(--success-alpha10)" : "var(--hover-bg)",
|
||||
color: editEnabled ? "var(--success)" : "var(--text-muted)"
|
||||
}}
|
||||
>
|
||||
{editEnabled ? "Enabled" : "Disabled"}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
@ -100,6 +248,13 @@ export function SkillDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteSkillDialog
|
||||
skill={skillDetail}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onDeleted={() => navigate(`/${projectName}/skills`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
132
src/app/project/skills/CreateSkillDialog.tsx
Normal file
132
src/app/project/skills/CreateSkillDialog.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
64
src/app/project/skills/DeleteSkillDialog.tsx
Normal file
64
src/app/project/skills/DeleteSkillDialog.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useSkillsQuery } from "@/hooks/useSkillsQuery";
|
||||
import { useSkillsQuery, useScanSkillsMutation } from "@/hooks/useSkillsQuery";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState } from "@/components/ui/LoadingState";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
import { Lightbulb, Calendar } from "lucide-react";
|
||||
import { Lightbulb, Calendar, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import type { SkillResponse } from "@/client/model";
|
||||
import { CreateSkillDialog } from "./CreateSkillDialog";
|
||||
import { DeleteSkillDialog } from "./DeleteSkillDialog";
|
||||
|
||||
export function SkillsPage() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: skills = [], isLoading, error, refetch } = useSkillsQuery(projectName);
|
||||
const { mutate: scanSkills, isPending: isScanning } = useScanSkillsMutation(projectName);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteSkill, setDeleteSkill] = useState<SkillResponse | null>(null);
|
||||
|
||||
if (!projectName) {
|
||||
return (
|
||||
@ -46,31 +54,60 @@ export function SkillsPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full overflow-auto">
|
||||
<PageHeader title="Skills" description="Project capabilities and AI skills" />
|
||||
<PageHeader
|
||||
title="Skills"
|
||||
description="Project capabilities and AI skills"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => scanSkills()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-1.5 ${isScanning ? "animate-spin" : ""}`} />
|
||||
{isScanning ? "Scanning..." : "Scan"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{skills.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Lightbulb className="w-6 h-6" />}
|
||||
title="No skills"
|
||||
description="Configure project skills to enable AI assistance"
|
||||
description="Create or scan skills to enable AI assistance"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{skills.map((skill: SkillResponse) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
onClick={() =>
|
||||
navigate(`/${projectName}/skills/${skill.slug}`)
|
||||
}
|
||||
className="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors cursor-pointer"
|
||||
className="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors cursor-pointer group relative"
|
||||
style={{ backgroundColor: "var(--surface-elevated)" }}
|
||||
onClick={() => navigate(`/${projectName}/skills/${skill.slug}`)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteSkill(skill);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Lightbulb className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-card-foreground mb-1 truncate">
|
||||
<h3 className="text-base font-semibold text-card-foreground mb-1 truncate pr-6">
|
||||
{skill.name}
|
||||
</h3>
|
||||
{skill.description && (
|
||||
@ -90,6 +127,15 @@ export function SkillsPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateSkillDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
{deleteSkill && (
|
||||
<DeleteSkillDialog
|
||||
skill={deleteSkill}
|
||||
open={!!deleteSkill}
|
||||
onOpenChange={(open) => { if (!open) setDeleteSkill(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import {Link, useLocation, useParams} from "react-router-dom";
|
||||
import {useRoomsQuery} from "@/hooks/useRoomsQuery";
|
||||
import {useProjectInfo} from "@/hooks/useProjectInfo";
|
||||
import {Hash, PanelLeftClose, Plus, Search, Settings} from "lucide-react";
|
||||
import {CHANNEL_SIDEBAR} from "@/css/layout/styles";
|
||||
import {useState} from "react";
|
||||
import {ProjectCreateMenuModal} from "@/app/project";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@ -38,7 +38,7 @@ interface ChannelSidebarProps {
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
|
||||
export const ChannelSidebar = memo(function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
|
||||
const location = useLocation();
|
||||
const {projectName} = useParams<{ projectName: string }>();
|
||||
const {data: roomsData, isLoading} = useRoomsQuery(projectName);
|
||||
@ -49,29 +49,32 @@ export function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
|
||||
const categories = roomsData?.categories ?? [];
|
||||
|
||||
const pathParts = location.pathname.split("/").filter(Boolean);
|
||||
const isActive = (path: string) => {
|
||||
if (pathParts.length >= 2) {
|
||||
return pathParts[1] === path;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isActive = useCallback((path: string) => {
|
||||
return pathParts.length >= 2 && pathParts[1] === path;
|
||||
}, [pathParts]);
|
||||
|
||||
const isRoomActive = (roomId: string) => {
|
||||
const isRoomActive = useCallback((roomId: string) => {
|
||||
return pathParts.length >= 3 && pathParts[2] === roomId;
|
||||
};
|
||||
}, [pathParts]);
|
||||
|
||||
const isSettingsActive = isActive("settings");
|
||||
|
||||
const showSettings = projectInfo?.role === "Owner" || projectInfo?.role === "Admin";
|
||||
|
||||
const uncategorizedRooms = rooms.filter((r) => !r.isMuted && !r.category);
|
||||
const categorizedRooms = [...categories]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((cat) => ({
|
||||
...cat,
|
||||
rooms: rooms.filter((r) => !r.isMuted && r.category === cat.id),
|
||||
}))
|
||||
.filter((cat) => cat.rooms.length > 0);
|
||||
const uncategorizedRooms = useMemo(
|
||||
() => rooms.filter((r) => !r.isMuted && !r.category),
|
||||
[rooms],
|
||||
);
|
||||
const categorizedRooms = useMemo(
|
||||
() => [...categories]
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((cat) => ({
|
||||
...cat,
|
||||
rooms: rooms.filter((r) => !r.isMuted && r.category === cat.id),
|
||||
}))
|
||||
.filter((cat) => cat.rooms.length > 0),
|
||||
[rooms, categories],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -291,4 +294,4 @@ export function ChannelSidebar({onCollapse}: ChannelSidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import { ChevronRight, Home, Settings } from "lucide-react";
|
||||
import { useProjectLayout } from "@/app/project/layout";
|
||||
import { useProjectsQuery } from "@/hooks/useProjectsQuery";
|
||||
import { useOptionalRoom } from "@/contexts/room";
|
||||
import { useState } from "react";
|
||||
import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@ -87,17 +87,17 @@ const TOOLBAR_ICONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
export const Header = memo(function Header() {
|
||||
const location = useLocation();
|
||||
const segments = useBreadcrumbs();
|
||||
const { showMembers, setShowMembers } = useProjectLayout();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const roomContext = useOptionalRoom();
|
||||
|
||||
const handleCopy = (e: React.MouseEvent, text: string) => {
|
||||
const handleCopy = useCallback((e: React.MouseEvent, text: string) => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -219,4 +219,4 @@ export function Header() {
|
||||
<RoomSettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { memo, useEffect, useState, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Avatar } from '@/components/channel/Avatar';
|
||||
import { useRoom } from '@/contexts/room';
|
||||
import { useOptionalRoom } from '@/contexts/room';
|
||||
import { projectMembersGrouped, projectInfo, projectPresence } from '@/client/api';
|
||||
import type { MemberGroup } from '@/client/model';
|
||||
import type { PresenceStatus } from '@/client/model';
|
||||
@ -19,17 +19,9 @@ const ROLE_COLORS: Record<string, string> = {
|
||||
member: 'var(--role-blue)',
|
||||
};
|
||||
|
||||
function useRoomSafe() {
|
||||
try {
|
||||
return useRoom();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function MemberList() {
|
||||
export const MemberList = memo(function MemberList() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const room = useRoomSafe();
|
||||
const room = useOptionalRoom();
|
||||
|
||||
const [groups, setGroups] = useState<MemberGroup[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@ -217,4 +209,4 @@ export function MemberList() {
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import {useCurrentUserQuery, useLogoutMutation} from "@/hooks/useAuth";
|
||||
import {useProjectsQuery} from "@/hooks/useProjectsQuery";
|
||||
@ -5,7 +6,6 @@ import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {useSettingsModal} from "@/components/settings/SettingsModalContext";
|
||||
import {LogOut, Settings, Home, Plus} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CreateProjectModal } from "@/app/me/components/CreateProjectModal";
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
@ -22,7 +22,7 @@ function hashColor(str: string): string {
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
export function ServerIconRail() {
|
||||
export const ServerIconRail = memo(function ServerIconRail() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { data: user } = useCurrentUserQuery();
|
||||
@ -36,12 +36,12 @@ export function ServerIconRail() {
|
||||
const pathParts = location.pathname.split("/").filter(Boolean);
|
||||
const currentProjectName = pathParts.length > 0 ? pathParts[0] : "";
|
||||
|
||||
const handleLogout = async () => {
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await logoutMutation.mutateAsync();
|
||||
} catch { }
|
||||
navigate("/auth/login", { replace: true });
|
||||
};
|
||||
}, [logoutMutation, navigate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -178,4 +178,4 @@ export function ServerIconRail() {
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => (
|
||||
<a
|
||||
@ -32,4 +36,4 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -4,49 +4,52 @@ const MOBILE_BREAKPOINT = 768
|
||||
const TABLET_BREAKPOINT = 1024
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean>(
|
||||
() => window.innerWidth < MOBILE_BREAKPOINT,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
export function useIsTablet() {
|
||||
const [isTablet, setIsTablet] = React.useState<boolean | undefined>(undefined)
|
||||
const [isTablet, setIsTablet] = React.useState<boolean>(
|
||||
() => window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${TABLET_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${TABLET_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsTablet(window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsTablet(window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsTablet(window.innerWidth >= MOBILE_BREAKPOINT && window.innerWidth < TABLET_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isTablet
|
||||
return isTablet;
|
||||
}
|
||||
|
||||
export function useIsDesktop() {
|
||||
const [isDesktop, setIsDesktop] = React.useState<boolean | undefined>(undefined)
|
||||
const [isDesktop, setIsDesktop] = React.useState<boolean>(
|
||||
() => window.innerWidth >= TABLET_BREAKPOINT,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(min-width: ${TABLET_BREAKPOINT}px)`)
|
||||
const mql = window.matchMedia(`(min-width: ${TABLET_BREAKPOINT}px)`);
|
||||
const onChange = () => {
|
||||
setIsDesktop(window.innerWidth >= TABLET_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsDesktop(window.innerWidth >= TABLET_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsDesktop(window.innerWidth >= TABLET_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isDesktop
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { skillList, skillGet, skillCreate } from "@/client/api";
|
||||
import type { SkillResponse, CreateSkillRequest } from "@/client/model";
|
||||
import { skillList, skillGet, skillCreate, skillUpdate, skillDelete, skillScan } from "@/client/api";
|
||||
import type { SkillResponse, CreateSkillRequest, UpdateSkillRequest } from "@/client/model";
|
||||
|
||||
const QUERY_KEY = "skills";
|
||||
|
||||
@ -51,3 +51,47 @@ export function useCreateSkillMutation(projectName: string | undefined) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSkillMutation(projectName: string | undefined) {
|
||||
const invalidate = useInvalidateSkills();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ slug, req }: { slug: string; req: UpdateSkillRequest }) => {
|
||||
if (!projectName) throw new Error("No project selected");
|
||||
const res = await skillUpdate(projectName, slug, req);
|
||||
return res.data?.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (projectName) invalidate(projectName);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSkillMutation(projectName: string | undefined) {
|
||||
const invalidate = useInvalidateSkills();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (slug: string) => {
|
||||
if (!projectName) throw new Error("No project selected");
|
||||
await skillDelete(projectName, slug);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (projectName) invalidate(projectName);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useScanSkillsMutation(projectName: string | undefined) {
|
||||
const invalidate = useInvalidateSkills();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!projectName) throw new Error("No project selected");
|
||||
const res = await skillScan(projectName);
|
||||
return res.data?.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (projectName) invalidate(projectName);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user