mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-30 14:25:00 +00:00
feat(skills): add edit modal, drag-and-drop upload, fix frontmatter stripping
- Add skill edit dialog (name, description, visibility, tags) - Add drag-and-drop support to upload modal with visual feedback - Fix frontmatter not stripped in skill detail view (CRLF normalization) - Include visibility, tags, version in skill list/detail API responses - Change default skill upload visibility from private to internal - Add visibility column and version display to skills table
This commit is contained in:
@@ -36,10 +36,17 @@ func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, re
|
||||
"slug": s.Slug,
|
||||
"description": s.Description,
|
||||
"source": s.Source,
|
||||
"version": s.Version,
|
||||
}
|
||||
if s.ID != "" {
|
||||
entry["id"] = s.ID
|
||||
}
|
||||
if s.Visibility != "" {
|
||||
entry["visibility"] = s.Visibility
|
||||
}
|
||||
if len(s.Tags) > 0 {
|
||||
entry["tags"] = s.Tags
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
@@ -68,12 +75,24 @@ func (m *SkillsMethods) handleGet(_ context.Context, client *gateway.Client, req
|
||||
|
||||
content, _ := m.store.LoadSkill(params.Name)
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
|
||||
resp := map[string]interface{}{
|
||||
"name": info.Name,
|
||||
"slug": info.Slug,
|
||||
"description": info.Description,
|
||||
"source": info.Source,
|
||||
"content": content,
|
||||
}))
|
||||
"version": info.Version,
|
||||
}
|
||||
if info.ID != "" {
|
||||
resp["id"] = info.ID
|
||||
}
|
||||
if info.Visibility != "" {
|
||||
resp["visibility"] = info.Visibility
|
||||
}
|
||||
if len(info.Tags) > 0 {
|
||||
resp["tags"] = info.Tags
|
||||
}
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, resp))
|
||||
}
|
||||
|
||||
// skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore).
|
||||
|
||||
@@ -270,7 +270,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
Slug: slug,
|
||||
Description: &desc,
|
||||
OwnerID: userID,
|
||||
Visibility: "private",
|
||||
Visibility: "internal",
|
||||
Version: version,
|
||||
FilePath: destDir,
|
||||
FileSize: size,
|
||||
|
||||
@@ -441,8 +441,16 @@ func parseMetadata(path string) *Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLineEndings converts \r\n and bare \r to \n so frontmatter regex matches
|
||||
// files created on Windows or uploaded via ZIP with CRLF line endings.
|
||||
func normalizeLineEndings(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
return s
|
||||
}
|
||||
|
||||
func extractFrontmatter(content string) string {
|
||||
match := frontmatterRe.FindStringSubmatch(content)
|
||||
match := frontmatterRe.FindStringSubmatch(normalizeLineEndings(content))
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
@@ -450,7 +458,7 @@ func extractFrontmatter(content string) string {
|
||||
}
|
||||
|
||||
func stripFrontmatter(content string) string {
|
||||
return frontmatterRe.ReplaceAllString(content, "")
|
||||
return frontmatterRe.ReplaceAllString(normalizeLineEndings(content), "")
|
||||
}
|
||||
|
||||
func parseSimpleYAML(content string) map[string]string {
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
@@ -61,7 +64,7 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo {
|
||||
|
||||
// Cache miss or TTL expired → query DB
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, slug, description, version FROM skills WHERE status = 'active' ORDER BY name`)
|
||||
`SELECT id, name, slug, description, visibility, tags, version FROM skills WHERE status = 'active' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -70,13 +73,17 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo {
|
||||
var result []store.SkillInfo
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var name, slug string
|
||||
var name, slug, visibility string
|
||||
var desc *string
|
||||
var tags []string
|
||||
var version int
|
||||
if err := rows.Scan(&id, &name, &slug, &desc, &version); err != nil {
|
||||
if err := rows.Scan(&id, &name, &slug, &desc, &visibility, pq.Array(&tags), &version); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir))
|
||||
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir)
|
||||
info.Visibility = visibility
|
||||
info.Tags = tags
|
||||
result = append(result, info)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -148,16 +155,20 @@ func (s *PGSkillStore) BuildSummary(allowList []string) string {
|
||||
}
|
||||
|
||||
func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) {
|
||||
var skillName, slug string
|
||||
var id uuid.UUID
|
||||
var skillName, slug, visibility string
|
||||
var desc *string
|
||||
var tags []string
|
||||
var version int
|
||||
err := s.db.QueryRow(
|
||||
"SELECT name, slug, description, version FROM skills WHERE slug = $1 AND status = 'active'", name,
|
||||
).Scan(&skillName, &slug, &desc, &version)
|
||||
"SELECT id, name, slug, description, visibility, tags, version FROM skills WHERE slug = $1 AND status = 'active'", name,
|
||||
).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
info := buildSkillInfo("", skillName, slug, desc, version, s.baseDir)
|
||||
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir)
|
||||
info.Visibility = visibility
|
||||
info.Tags = tags
|
||||
return &info, true
|
||||
}
|
||||
|
||||
@@ -439,14 +450,22 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st
|
||||
BaseDir: fmt.Sprintf("%s/%s/%d", baseDir, slug, version),
|
||||
Source: "managed",
|
||||
Description: d,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
|
||||
// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
|
||||
var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
|
||||
|
||||
func readSkillContent(baseDir, slug string, version int) (string, error) {
|
||||
path := fmt.Sprintf("%s/%s/%d/SKILL.md", baseDir, slug, version)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
// Normalize line endings (Windows CRLF → LF) and strip frontmatter
|
||||
content := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
content = skillFrontmatterRe.ReplaceAllString(content, "")
|
||||
return content, nil
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@ import (
|
||||
|
||||
// SkillInfo describes a discovered skill.
|
||||
type SkillInfo struct {
|
||||
ID string `json:"id,omitempty"` // DB UUID
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Path string `json:"path"`
|
||||
BaseDir string `json:"baseDir"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
ID string `json:"id,omitempty"` // DB UUID
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Path string `json:"path"`
|
||||
BaseDir string `json:"baseDir"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// SkillSearchResult is a scored skill returned from embedding search.
|
||||
|
||||
@@ -51,6 +51,15 @@ export function useSkills() {
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const updateSkill = useCallback(
|
||||
async (id: string, updates: Record<string, unknown>) => {
|
||||
const res = await http.put<{ ok: string }>(`/v1/skills/${id}`, updates);
|
||||
await invalidate();
|
||||
return res;
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteSkill = useCallback(
|
||||
async (id: string) => {
|
||||
const res = await http.delete<{ ok: string }>(`/v1/skills/${id}`);
|
||||
@@ -60,5 +69,5 @@ export function useSkills() {
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
return { skills, loading, refresh: invalidate, getSkill, uploadSkill, deleteSkill };
|
||||
return { skills, loading, refresh: invalidate, getSkill, uploadSkill, updateSkill, deleteSkill };
|
||||
}
|
||||
|
||||
@@ -21,14 +21,27 @@ export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) {
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{skill.name}
|
||||
<Badge variant="outline">{skill.source || "file"}</Badge>
|
||||
{skill.visibility && (
|
||||
<Badge variant="secondary">{skill.visibility}</Badge>
|
||||
)}
|
||||
{skill.version ? (
|
||||
<span className="text-xs font-normal text-muted-foreground">v{skill.version}</span>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
{skill.tags && skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{skill.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="mt-2">
|
||||
{skill.content ? (
|
||||
<div className="rounded-md border bg-muted/30 p-4">
|
||||
<div className="overflow-hidden rounded-md border bg-muted/30 p-4">
|
||||
<MarkdownRenderer content={skill.content} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { SkillInfo } from "@/types/skill";
|
||||
|
||||
interface SkillEditDialogProps {
|
||||
skill: SkillInfo;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, updates: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps) {
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description);
|
||||
const [visibility, setVisibility] = useState(skill.visibility ?? "private");
|
||||
const [tags, setTags] = useState<string[]>(skill.tags ?? []);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setName(skill.name);
|
||||
setDescription(skill.description);
|
||||
setVisibility(skill.visibility ?? "private");
|
||||
setTags(skill.tags ?? []);
|
||||
}, [skill]);
|
||||
|
||||
const addTag = () => {
|
||||
const t = tagInput.trim().toLowerCase();
|
||||
if (t && !tags.includes(t)) {
|
||||
setTags([...tags, t]);
|
||||
}
|
||||
setTagInput("");
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!skill.id) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await onSave(skill.id, { name, description, visibility, tags });
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Skill</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="skill-name">Name</Label>
|
||||
<Input
|
||||
id="skill-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="skill-desc">Description</Label>
|
||||
<Textarea
|
||||
id="skill-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Visibility</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private (owner only)</SelectItem>
|
||||
<SelectItem value="internal">Internal (granted agents/users)</SelectItem>
|
||||
<SelectItem value="public">Public (all agents)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tags</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); addTag(); }
|
||||
}}
|
||||
placeholder="Add tag..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addTag}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || !name.trim()}>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -41,10 +42,25 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
if (!loading) {
|
||||
setFile(null);
|
||||
setError("");
|
||||
setDragging(false);
|
||||
onOpenChange(v);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptDrop = (f: File): boolean => f.name.toLowerCase().endsWith(".zip");
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (dropped && acceptDrop(dropped)) {
|
||||
setFile(dropped);
|
||||
setError("");
|
||||
} else {
|
||||
setError("Only .zip files are accepted");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
@@ -56,14 +72,24 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed p-8 text-center transition-colors hover:border-primary/50"
|
||||
className={`flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed p-8 text-center transition-colors ${
|
||||
dragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
{file ? (
|
||||
<p className="text-sm font-medium">{file.name} ({(file.size / 1024).toFixed(1)} KB)</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Click to select a .zip file</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dragging ? "Drop .zip file here" : "Click or drag & drop a .zip file"}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Zap, Eye, RefreshCw, Upload, Trash2 } from "lucide-react";
|
||||
import { Zap, Eye, Pencil, RefreshCw, Upload, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
@@ -11,18 +11,26 @@ import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
import { useSkills, type SkillInfo } from "./hooks/use-skills";
|
||||
import { SkillDetailDialog } from "./skill-detail-dialog";
|
||||
import { SkillUploadDialog } from "./skill-upload-dialog";
|
||||
import { SkillEditDialog } from "./skill-edit-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
|
||||
const visibilityColor: Record<string, string> = {
|
||||
public: "default",
|
||||
internal: "secondary",
|
||||
private: "outline",
|
||||
};
|
||||
|
||||
export function SkillsPage() {
|
||||
const { skills, loading, refresh, getSkill, uploadSkill, deleteSkill } = useSkills();
|
||||
const { skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill } = useSkills();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<SkillInfo | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SkillInfo | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
@@ -106,6 +114,7 @@ export function SkillsPage() {
|
||||
<th className="px-4 py-3 text-left font-medium">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Description</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Source</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Visibility</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -116,34 +125,54 @@ export function SkillsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
{skill.version ? (
|
||||
<span className="text-xs text-muted-foreground">v{skill.version}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
<td className="max-w-xs truncate px-4 py-3 text-muted-foreground">
|
||||
{skill.description || "No description"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline">{skill.source || "file"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{skill.visibility && (
|
||||
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
|
||||
{skill.visibility}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewSkill(skill.name)}
|
||||
onClick={() => handleViewSkill(skill.slug ?? skill.name)}
|
||||
disabled={detailLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" /> View
|
||||
</Button>
|
||||
{skill.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(skill)}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditTarget(skill)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(skill)}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -170,6 +199,17 @@ export function SkillsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{editTarget && (
|
||||
<SkillEditDialog
|
||||
skill={editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
onSave={async (id, updates) => {
|
||||
await updateSkill(id, updates);
|
||||
setEditTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SkillUploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
|
||||
@@ -4,6 +4,9 @@ export interface SkillInfo {
|
||||
slug?: string;
|
||||
description: string;
|
||||
source: string;
|
||||
visibility?: string;
|
||||
tags?: string[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface SkillWithGrant {
|
||||
|
||||
Reference in New Issue
Block a user