feat: polish skills pane ui
- share skill card and markdown rendering - clean up skill detail layout and scroll reset - remove nested interactive card structure
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ArrowLeft, Trash2 } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import PaneHeader from './PaneHeader';
|
import PaneHeader from './PaneHeader';
|
||||||
import type { BasePaneProps } from './types';
|
import type { BasePaneProps } from './types';
|
||||||
|
import SkillCard from '@/components/skills/SkillCard';
|
||||||
|
import SkillMarkdown from '@/components/skills/SkillMarkdown';
|
||||||
|
|
||||||
interface SkillMeta {
|
interface SkillMeta {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -27,12 +27,13 @@ export default function SkillsPane({
|
|||||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const detailScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSkills();
|
void fetchSkills();
|
||||||
|
|
||||||
const handleSkillUpdated = () => {
|
const handleSkillUpdated = () => {
|
||||||
fetchSkills();
|
void fetchSkills();
|
||||||
};
|
};
|
||||||
window.addEventListener('skills:updated', handleSkillUpdated);
|
window.addEventListener('skills:updated', handleSkillUpdated);
|
||||||
window.addEventListener('guides:updated', handleSkillUpdated);
|
window.addEventListener('guides:updated', handleSkillUpdated);
|
||||||
@@ -43,6 +44,12 @@ export default function SkillsPane({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSkill && detailScrollRef.current) {
|
||||||
|
detailScrollRef.current.scrollTo({ top: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
|
}, [selectedSkill?.name]);
|
||||||
|
|
||||||
const fetchSkills = async () => {
|
const fetchSkills = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/skills');
|
const res = await fetch('/api/skills');
|
||||||
@@ -69,7 +76,7 @@ export default function SkillsPane({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSkill = async (name: string, e: React.MouseEvent) => {
|
const handleDeleteSkill = async (name: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!confirm(`Delete skill "${name}"?`)) return;
|
if (!confirm(`Delete skill "${name}"?`)) return;
|
||||||
|
|
||||||
@@ -78,7 +85,7 @@ export default function SkillsPane({
|
|||||||
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
fetchSkills();
|
await fetchSkills();
|
||||||
if (selectedSkill?.name === name) {
|
if (selectedSkill?.name === name) {
|
||||||
setSelectedSkill(null);
|
setSelectedSkill(null);
|
||||||
}
|
}
|
||||||
@@ -104,6 +111,7 @@ export default function SkillsPane({
|
|||||||
{selectedSkill ? (
|
{selectedSkill ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSelectedSkill(null)}
|
onClick={() => setSelectedSkill(null)}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
@@ -115,12 +123,6 @@ export default function SkillsPane({
|
|||||||
padding: '4px',
|
padding: '4px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.color = '#ccc';
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.color = '#888';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -131,12 +133,17 @@ export default function SkillsPane({
|
|||||||
)}
|
)}
|
||||||
</PaneHeader>
|
</PaneHeader>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }} ref={detailScrollRef}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div>
|
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div>
|
||||||
) : selectedSkill ? (
|
) : selectedSkill ? (
|
||||||
<div className="skill-content" style={{ color: '#ccc', fontSize: '13px', lineHeight: '1.6' }}>
|
<div>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selectedSkill.content}</ReactMarkdown>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#eee', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div>
|
||||||
|
<div style={{ color: '#888', fontSize: '13px', lineHeight: 1.4, marginTop: '6px' }}>{selectedSkill.description}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: '1px solid #222', margin: '12px 0' }} />
|
||||||
|
<SkillMarkdown content={selectedSkill.content} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
@@ -146,69 +153,13 @@ export default function SkillsPane({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
skills.map((skill) => (
|
skills.map((skill) => (
|
||||||
<button
|
<SkillCard
|
||||||
key={skill.name}
|
key={skill.name}
|
||||||
onClick={() => handleSelectSkill(skill.name)}
|
skill={skill}
|
||||||
style={{
|
onSelect={handleSelectSkill}
|
||||||
display: 'flex',
|
onDelete={handleDeleteSkill}
|
||||||
alignItems: 'center',
|
deleting={deleting}
|
||||||
gap: '8px',
|
/>
|
||||||
padding: '12px',
|
|
||||||
background: '#161616',
|
|
||||||
border: '1px solid #222',
|
|
||||||
borderRadius: '8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.background = '#1a1a1a';
|
|
||||||
e.currentTarget.style.borderColor = '#333';
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.background = '#161616';
|
|
||||||
e.currentTarget.style.borderColor = '#222';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 500 }}>{skill.name}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: '#777',
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
display: 'block',
|
|
||||||
marginTop: '2px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{skill.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={e => handleDeleteSkill(skill.name, e)}
|
|
||||||
disabled={deleting === skill.name}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: '#555',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
opacity: deleting === skill.name ? 0.3 : 1,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.color = '#ef4444';
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.color = '#555';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</button>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SkillMeta {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
immutable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillCardProps {
|
||||||
|
skill: SkillMeta;
|
||||||
|
onSelect: (name: string) => void;
|
||||||
|
onDelete?: (name: string, e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
deleting?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillCard({
|
||||||
|
skill,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
deleting = null,
|
||||||
|
}: SkillCardProps) {
|
||||||
|
const isDeleting = deleting === skill.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#1a1a1a';
|
||||||
|
e.currentTarget.style.borderColor = '#383838';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#161616';
|
||||||
|
e.currentTarget.style.borderColor = '#222';
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '10px',
|
||||||
|
background: '#161616',
|
||||||
|
border: '1px solid #222',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(skill.name)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 500, display: 'block' }}>{skill.name}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: '#777',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
display: 'block',
|
||||||
|
marginTop: '2px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={skill.description}
|
||||||
|
>
|
||||||
|
{skill.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{onDelete && !skill.immutable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => onDelete(skill.name, e)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#555',
|
||||||
|
cursor: isDeleting ? 'default' : 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: isDeleting ? 0.3 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
export default function SkillMarkdown({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="skill-content" style={{ color: '#ccc', fontSize: '13px', lineHeight: '1.6' }}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 style={{ fontSize: '18px', fontWeight: 600, color: '#eee', margin: '0 0 16px 0' }}>{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 style={{ fontSize: '15px', fontWeight: 600, color: '#ddd', margin: '20px 0 8px 0' }}>{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: 600, color: '#ccc', margin: '16px 0 6px 0' }}>{children}</h3>
|
||||||
|
),
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p style={{ margin: '0 0 12px 0' }}>{children}</p>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol style={{ margin: '0 0 12px 0', paddingLeft: '20px' }}>{children}</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => (
|
||||||
|
<li style={{ margin: '0 0 4px 0' }}>{children}</li>
|
||||||
|
),
|
||||||
|
code: ({ className, children, ...props }) => {
|
||||||
|
const isInline = !className;
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#22c55e',
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
background: '#0d0d0d',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
margin: '0 0 12px 0',
|
||||||
|
color: '#aaa',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre style={{ margin: '0 0 12px 0' }}>{children}</pre>
|
||||||
|
),
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong style={{ color: '#eee', fontWeight: 600 }}>{children}</strong>
|
||||||
|
),
|
||||||
|
hr: () => (
|
||||||
|
<hr style={{ border: 'none', borderTop: '1px solid #2a2a2a', margin: '16px 0' }} />
|
||||||
|
),
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote
|
||||||
|
style={{
|
||||||
|
borderLeft: '3px solid #333',
|
||||||
|
paddingLeft: '12px',
|
||||||
|
margin: '0 0 12px 0',
|
||||||
|
color: '#999',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user