mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-28 06:24:40 +00:00
fix(web): rework skills layout and move editor into a drawer
Replace the two card grids with one full-width list plus scope filter pills, so content fills the column instead of a third of it. Move the editor into a 720px right drawer, lift scope above the body, make description a textarea, and put Write/Preview on the instructions field. Also fixes the instructions placeholder rendering a literal \n.
This commit is contained in:
parent
2761dc98c2
commit
1afe611b29
4 changed files with 468 additions and 388 deletions
|
|
@ -12,6 +12,12 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from "@ui/components/sheet"
|
||||
import { FileUp, Loader2, Plus, RotateCcw } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -26,14 +32,29 @@ import {
|
|||
parseSkillMarkdown,
|
||||
skillDraftForRole,
|
||||
type SkillDraft,
|
||||
type SkillScope,
|
||||
} from "./company-brain-skills/domain"
|
||||
import { SkillCard } from "./company-brain-skills/skill-card"
|
||||
import { SkillEditor } from "./company-brain-skills/skill-editor"
|
||||
import { SkillRow } from "./company-brain-skills/skill-row"
|
||||
|
||||
type UnsavedDraft = {
|
||||
key: number
|
||||
kind: "new" | "upload"
|
||||
draft: SkillDraft
|
||||
type EditorTarget =
|
||||
| { mode: "existing"; skillId: string }
|
||||
| { mode: "new" | "upload"; key: number; draft: SkillDraft }
|
||||
|
||||
type PendingIntent = { type: "close" } | { type: "open"; target: EditorTarget }
|
||||
|
||||
type ScopeFilter = "all" | "org" | "personal"
|
||||
|
||||
const SCOPE_HINTS: Record<ScopeFilter, string> = {
|
||||
all: "Org-wide skills are shared with the workspace and managed by admins and owners. Personal skills stay private to you.",
|
||||
org: "Shared with the workspace and managed by admins and owners.",
|
||||
personal: "Private to you and managed only by you.",
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: Record<ScopeFilter, string> = {
|
||||
all: "No skills yet. Create one or upload a .md playbook to get started.",
|
||||
org: "No org-wide skills yet.",
|
||||
personal: "No personal skills yet.",
|
||||
}
|
||||
|
||||
function draftFromSkill(skill: BrainSkill): SkillDraft {
|
||||
|
|
@ -88,14 +109,15 @@ function CompanyBrainSkillsContent({
|
|||
}) {
|
||||
const skillsQuery = useBrainSkills()
|
||||
const upload = useUploadBrainSkill()
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const [openSkillDirty, setOpenSkillDirty] = useState(false)
|
||||
const [pendingOpenId, setPendingOpenId] = useState<string | null>(null)
|
||||
const [drafts, setDrafts] = useState<UnsavedDraft[]>([])
|
||||
const [editing, setEditing] = useState<EditorTarget | null>(null)
|
||||
const [editorDirty, setEditorDirty] = useState(false)
|
||||
const [pending, setPending] = useState<PendingIntent | null>(null)
|
||||
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const draftKey = useRef(0)
|
||||
const hasUnsavedChanges = openSkillDirty || drafts.length > 0
|
||||
// An uploaded draft holds real content the user hasn't saved, so it guards too.
|
||||
const hasUnsavedChanges = editorDirty || editing?.mode === "upload"
|
||||
|
||||
useEffect(() => {
|
||||
onUnsavedChangesChange?.(hasUnsavedChanges)
|
||||
|
|
@ -111,37 +133,41 @@ function CompanyBrainSkillsContent({
|
|||
const data = skillsQuery.data
|
||||
const viewerId = data?.viewerId ?? userId
|
||||
const isAdmin = data?.isAdmin ?? false
|
||||
const addDraft = (kind: UnsavedDraft["kind"], draft: SkillDraft) => {
|
||||
setDrafts((current) => [
|
||||
...current,
|
||||
{
|
||||
key: draftKey.current++,
|
||||
kind,
|
||||
draft: skillDraftForRole(draft, isAdmin),
|
||||
},
|
||||
])
|
||||
const openEditor = (target: EditorTarget) => {
|
||||
setEditing(target)
|
||||
setEditorDirty(false)
|
||||
}
|
||||
const removeDraft = (key: number) => {
|
||||
setDrafts((current) => current.filter((item) => item.key !== key))
|
||||
const closeEditor = () => {
|
||||
setEditing(null)
|
||||
setEditorDirty(false)
|
||||
setPending(null)
|
||||
}
|
||||
const closeOpenSkill = () => {
|
||||
setOpenId(null)
|
||||
setOpenSkillDirty(false)
|
||||
setPendingOpenId(null)
|
||||
}
|
||||
const requestOpenSkill = (skillId: string) => {
|
||||
if (openId && openId !== skillId && openSkillDirty) {
|
||||
setPendingOpenId(skillId)
|
||||
const requestOpen = (target: EditorTarget) => {
|
||||
if (editing && hasUnsavedChanges) {
|
||||
setPending({ type: "open", target })
|
||||
return
|
||||
}
|
||||
setOpenId(skillId)
|
||||
setOpenSkillDirty(false)
|
||||
openEditor(target)
|
||||
}
|
||||
const confirmOpenSkill = () => {
|
||||
if (!pendingOpenId) return
|
||||
setOpenId(pendingOpenId)
|
||||
setOpenSkillDirty(false)
|
||||
setPendingOpenId(null)
|
||||
const requestClose = () => {
|
||||
if (editing && hasUnsavedChanges) {
|
||||
setPending({ type: "close" })
|
||||
return
|
||||
}
|
||||
closeEditor()
|
||||
}
|
||||
const confirmPending = () => {
|
||||
if (!pending) return
|
||||
if (pending.type === "close") closeEditor()
|
||||
else openEditor(pending.target)
|
||||
setPending(null)
|
||||
}
|
||||
const openDraft = (kind: "new" | "upload", draft: SkillDraft) => {
|
||||
requestOpen({
|
||||
mode: kind,
|
||||
key: draftKey.current++,
|
||||
draft: skillDraftForRole(draft, isAdmin),
|
||||
})
|
||||
}
|
||||
|
||||
const onUpload = async (file: File | undefined) => {
|
||||
|
|
@ -158,7 +184,7 @@ function CompanyBrainSkillsContent({
|
|||
// the same validation and remains authoritative before the draft opens.
|
||||
const local = parseSkillMarkdown(content)
|
||||
const result = await upload.mutateAsync(content)
|
||||
addDraft("upload", uploadedDraft(local, result.draft))
|
||||
openDraft("upload", uploadedDraft(local, result.draft))
|
||||
toast.success("Skill file is ready to review.")
|
||||
} catch (error) {
|
||||
setUploadError(errorMessage(error, "Couldn't read this skill file."))
|
||||
|
|
@ -171,91 +197,30 @@ function CompanyBrainSkillsContent({
|
|||
const personalSkills = (data?.skills ?? []).filter(
|
||||
(skill) => skill.scope === "personal" && skill.creatorUserId === viewerId,
|
||||
)
|
||||
const orgDrafts = drafts.filter((draft) => draft.draft.scope === "org")
|
||||
const personalDrafts = drafts.filter(
|
||||
(draft) => draft.draft.scope === "personal",
|
||||
)
|
||||
const renderSkill = (skill: BrainSkill) =>
|
||||
openId === skill.id ? (
|
||||
<div key={skill.id} className="sm:col-span-2 lg:col-span-3">
|
||||
<SkillEditor
|
||||
key={`${skill.id}:${skill.version}:${skill.updatedAt}`}
|
||||
skill={skill}
|
||||
initialDraft={draftFromSkill(skill)}
|
||||
isAdmin={isAdmin}
|
||||
viewerId={viewerId}
|
||||
onClose={closeOpenSkill}
|
||||
onDirtyChange={setOpenSkillDirty}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onOpen={() => requestOpenSkill(skill.id)}
|
||||
/>
|
||||
)
|
||||
const renderDraft = ({ key, kind, draft }: UnsavedDraft) => (
|
||||
<div key={key} className="sm:col-span-2 lg:col-span-3">
|
||||
<SkillEditor
|
||||
skill={null}
|
||||
initialDraft={draft}
|
||||
isAdmin={isAdmin}
|
||||
viewerId={viewerId}
|
||||
draftKind={kind}
|
||||
createOrigin={kind === "upload" ? "upload" : "web"}
|
||||
onClose={() => removeDraft(key)}
|
||||
/>
|
||||
</div>
|
||||
const visibleSkills =
|
||||
scopeFilter === "org"
|
||||
? orgSkills
|
||||
: scopeFilter === "personal"
|
||||
? personalSkills
|
||||
: [...orgSkills, ...personalSkills]
|
||||
const newSkillScope: SkillScope =
|
||||
scopeFilter === "org" && isAdmin ? "org" : "personal"
|
||||
const target = editing
|
||||
const editingSkill =
|
||||
target?.mode === "existing"
|
||||
? ((data?.skills ?? []).find((skill) => skill.id === target.skillId) ??
|
||||
null)
|
||||
: null
|
||||
const renderSkill = (skill: BrainSkill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onOpen={() => requestOpen({ mode: "existing", skillId: skill.id })}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={cn(dmSans125ClassName(), "flex flex-col gap-3 px-1")}>
|
||||
<div className="flex justify-end">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0]
|
||||
event.currentTarget.value = ""
|
||||
void onUpload(file)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={upload.isPending}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/[0.09] px-4 text-[12px] font-medium text-[#9AA3B2] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<FileUp className="size-4" />
|
||||
)}
|
||||
{upload.isPending ? "Reading…" : "Upload .md"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-300"
|
||||
>
|
||||
<span>{uploadError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadError(null)}
|
||||
className="shrink-0 text-[11px] text-red-200 hover:text-white"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className={cn(dmSans125ClassName(), "flex flex-col gap-3")}>
|
||||
{skillsQuery.isLoading ? (
|
||||
<div className="flex min-h-32 items-center justify-center gap-2 text-[13px] text-[#7E8794]">
|
||||
<Loader2 className="size-4 animate-spin" /> Loading skills…
|
||||
|
|
@ -284,106 +249,194 @@ function CompanyBrainSkillsContent({
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-7">
|
||||
<section
|
||||
aria-labelledby="org-skills-heading"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
id="org-skills-heading"
|
||||
className="text-[13px] font-semibold text-[#E5E7EB]"
|
||||
>
|
||||
Org-wide skills
|
||||
</h3>
|
||||
<p className="mt-0.5 text-[11px] text-[#596270]">
|
||||
Shared with the workspace and managed by admins and owners.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Filter skills by scope"
|
||||
className="flex items-center gap-0.5 rounded-full bg-white/[0.04] p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
id: "all",
|
||||
label: "All",
|
||||
count: orgSkills.length + personalSkills.length,
|
||||
},
|
||||
{ id: "org", label: "Org-wide", count: orgSkills.length },
|
||||
{
|
||||
id: "personal",
|
||||
label: "Personal",
|
||||
count: personalSkills.length,
|
||||
},
|
||||
] as const
|
||||
).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scopeFilter === tab.id}
|
||||
onClick={() => setScopeFilter(tab.id)}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium transition-colors",
|
||||
scopeFilter === tab.id
|
||||
? "bg-white/[0.09] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 ? (
|
||||
<span className="tabular-nums text-[11px] text-[#6B7482]">
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{orgSkills.length > 0 || orgDrafts.length > 0 || isAdmin ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{orgSkills.map(renderSkill)}
|
||||
{orgDrafts.map(renderDraft)}
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addDraft("new", {
|
||||
...emptySkillDraft(),
|
||||
scope: "org",
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[150px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" /> New org-wide skill
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-xl border border-white/[0.06] border-dashed px-4 py-6 text-center text-[12px] text-[#596270]">
|
||||
No org-wide skills yet.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="personal-skills-heading"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
id="personal-skills-heading"
|
||||
className="text-[13px] font-semibold text-[#E5E7EB]"
|
||||
>
|
||||
Personal skills
|
||||
</h3>
|
||||
<p className="mt-0.5 text-[11px] text-[#596270]">
|
||||
Private to you and managed only by you.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{personalSkills.map(renderSkill)}
|
||||
{personalDrafts.map(renderDraft)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0]
|
||||
event.currentTarget.value = ""
|
||||
void onUpload(file)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft("new", emptySkillDraft())}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[150px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
disabled={upload.isPending}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-full border border-white/[0.09] px-3.5 text-[12px] font-medium text-[#9AA3B2] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<Plus className="size-4" /> New personal skill
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<FileUp className="size-4" />
|
||||
)}
|
||||
{upload.isPending ? "Reading…" : "Upload .md"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openDraft("new", {
|
||||
...emptySkillDraft(),
|
||||
scope: newSkillScope,
|
||||
})
|
||||
}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-full bg-white/[0.09] px-3.5 text-[12px] font-medium text-[#FAFAFA] transition-colors hover:bg-white/[0.14]"
|
||||
>
|
||||
<Plus className="size-4" /> New skill
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-5 text-[#596270]">
|
||||
{SCOPE_HINTS[scopeFilter]}
|
||||
</p>
|
||||
|
||||
{uploadError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-300"
|
||||
>
|
||||
<span>{uploadError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadError(null)}
|
||||
className="shrink-0 text-[11px] text-red-200 hover:text-white"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleSkills.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-[#14161A]">
|
||||
<div className="divide-y divide-white/[0.05]">
|
||||
{visibleSkills.map(renderSkill)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-xl border border-white/[0.06] border-dashed px-4 py-5 text-center text-[12px] text-[#596270]">
|
||||
{EMPTY_MESSAGES[scopeFilter]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={pendingOpenId !== null}
|
||||
<Sheet
|
||||
open={target !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingOpenId(null)
|
||||
if (!open) requestClose()
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 border-white/[0.06] bg-[#14161A] p-0 sm:max-w-[720px]"
|
||||
>
|
||||
<SheetTitle className="sr-only">Skill editor</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Edit the name, scope, description and Markdown instructions for this
|
||||
skill.
|
||||
</SheetDescription>
|
||||
{target?.mode === "existing" ? (
|
||||
editingSkill ? (
|
||||
<SkillEditor
|
||||
key={`${editingSkill.id}:${editingSkill.version}:${editingSkill.updatedAt}`}
|
||||
skill={editingSkill}
|
||||
initialDraft={draftFromSkill(editingSkill)}
|
||||
isAdmin={isAdmin}
|
||||
viewerId={viewerId}
|
||||
onClose={closeEditor}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
) : (
|
||||
<p className="p-5 text-[13px] text-[#8B929E]">
|
||||
This skill is no longer available.
|
||||
</p>
|
||||
)
|
||||
) : target ? (
|
||||
<SkillEditor
|
||||
key={target.key}
|
||||
skill={null}
|
||||
initialDraft={target.draft}
|
||||
isAdmin={isAdmin}
|
||||
viewerId={viewerId}
|
||||
draftKind={target.mode}
|
||||
createOrigin={target.mode === "upload" ? "upload" : "web"}
|
||||
onClose={closeEditor}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={pending !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPending(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="border-white/[0.08] bg-[#191D24] text-[#FAFAFA]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#8B929E]">
|
||||
Opening another skill will discard the edits in this skill.
|
||||
{pending?.type === "open"
|
||||
? "Opening another skill will discard the edits in this skill."
|
||||
: "Closing the editor will discard the edits in this skill."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmOpenSkill}
|
||||
onClick={confirmPending}
|
||||
className="bg-red-600 text-white hover:bg-red-500"
|
||||
>
|
||||
Discard and open
|
||||
{pending?.type === "open" ? "Discard and open" : "Discard"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import type { BrainSkill } from "@/hooks/use-brain-skills"
|
||||
|
||||
function StatusChip({ skill }: { skill: BrainSkill }) {
|
||||
if (skill.status === "disabled") {
|
||||
return (
|
||||
<span className="inline-flex h-6 items-center rounded-full border border-red-400/20 bg-red-400/[0.07] px-2 text-[10px] font-medium text-red-300">
|
||||
Disabled
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function SkillCard({
|
||||
skill,
|
||||
onOpen,
|
||||
}: {
|
||||
skill: BrainSkill
|
||||
onOpen: () => void
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[150px] min-w-0 flex-col rounded-xl bg-[#14161A]",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors hover:bg-[#171A1F]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label={`${skill.canEdit ? "Edit" : "View"} ${skill.name}`}
|
||||
className="flex min-h-0 flex-1 flex-col p-4 text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[14px] font-semibold tracking-[-0.15px] text-[#FAFAFA]">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p className="mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737B87]">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-1.5 pt-4">
|
||||
<StatusChip skill={skill} />
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -242,66 +242,41 @@ export function SkillEditor({
|
|||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"relative flex flex-col gap-4 rounded-[14px] bg-[#14161A] p-5",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
"flex min-h-0 flex-1 flex-col bg-[#14161A]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-white/[0.05] border-b pb-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-[#FAFAFA]">
|
||||
{draftKind === "upload"
|
||||
? "Review uploaded skill"
|
||||
: skill
|
||||
? canManage
|
||||
? "Edit skill"
|
||||
: "View skill"
|
||||
: "New skill"}
|
||||
</p>
|
||||
{draftKind === "upload" ? (
|
||||
<p className="mt-0.5 text-[11px] text-[#687282]">Not saved yet</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="inline-flex rounded-full border border-white/[0.08] bg-[#0D0F14] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(false)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
!preview ? "bg-white/[0.08] text-[#FAFAFA]" : "text-[#687282]",
|
||||
)}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(true)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
preview ? "bg-white/[0.08] text-[#FAFAFA]" : "text-[#687282]",
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-w-0 shrink-0 flex-col border-white/[0.05] border-b px-5 py-4 pr-14">
|
||||
<p className="text-[13px] font-semibold text-[#FAFAFA]">
|
||||
{draftKind === "upload"
|
||||
? "Review uploaded skill"
|
||||
: skill
|
||||
? canManage
|
||||
? "Edit skill"
|
||||
: "View skill"
|
||||
: "New skill"}
|
||||
</p>
|
||||
{draftKind === "upload" ? (
|
||||
<p className="mt-0.5 text-[11px] text-[#687282]">Not saved yet</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!canManage ? (
|
||||
<p className="rounded-[9px] border border-white/[0.07] bg-white/[0.025] px-3 py-2 text-[12px] text-[#8B929E]">
|
||||
{skill?.scope === "personal"
|
||||
? "Only this skill’s creator can edit or delete it. Admin access does not override personal ownership. You can still read the full playbook below."
|
||||
: isAdmin
|
||||
? "You can view this org-wide skill, but it cannot be edited or deleted in its current state."
|
||||
: "Only admins and owners can edit or delete org-wide skills. You can still read the full playbook below."}
|
||||
</p>
|
||||
) : null}
|
||||
{skill?.status === "disabled" ? (
|
||||
<p className="rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-200">
|
||||
This skill is disabled.
|
||||
{skill.rejectionReason ? ` Reason: ${skill.rejectionReason}` : null}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]">
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||
{!canManage ? (
|
||||
<p className="rounded-[9px] border border-white/[0.07] bg-white/[0.025] px-3 py-2 text-[12px] text-[#8B929E]">
|
||||
{skill?.scope === "personal"
|
||||
? "Only this skill’s creator can edit or delete it. Admin access does not override personal ownership. You can still read the full playbook below."
|
||||
: isAdmin
|
||||
? "You can view this org-wide skill, but it cannot be edited or deleted in its current state."
|
||||
: "Only admins and owners can edit or delete org-wide skills. You can still read the full playbook below."}
|
||||
</p>
|
||||
) : null}
|
||||
{skill?.status === "disabled" ? (
|
||||
<p className="rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-200">
|
||||
This skill is disabled.
|
||||
{skill.rejectionReason ? ` Reason: ${skill.rejectionReason}` : null}
|
||||
</p>
|
||||
) : null}
|
||||
<label className="flex min-w-0 shrink-0 flex-col gap-1.5">
|
||||
<span className={labelClass}>Name</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
|
|
@ -312,101 +287,135 @@ export function SkillEditor({
|
|||
className={cn(inputClass, "h-9")}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={labelClass}>Description</span>
|
||||
<span className="text-[10px] text-[#596270]">
|
||||
{draft.description.length}/{SKILL_DESCRIPTION_MAX_LENGTH}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
value={draft.description}
|
||||
onChange={(event) => set("description", event.target.value)}
|
||||
readOnly={!canManage}
|
||||
disabled={busy}
|
||||
maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
|
||||
placeholder="When should the brain use this playbook?"
|
||||
className={cn(inputClass, "h-9")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={labelClass}>Instructions (Markdown)</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
bodyBytes > SKILL_BODY_MAX_LENGTH
|
||||
? "text-red-300"
|
||||
: "text-[#596270]",
|
||||
)}
|
||||
>
|
||||
{Math.ceil(bodyBytes / 1024)} / 16 KB
|
||||
</span>
|
||||
</span>
|
||||
{preview || !canManage ? (
|
||||
<MarkdownPreview body={draft.body} />
|
||||
) : (
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => set("body", event.target.value)}
|
||||
disabled={busy}
|
||||
maxLength={SKILL_BODY_MAX_LENGTH}
|
||||
spellCheck={false}
|
||||
placeholder="# Process\n\nDescribe the steps, format, and voice to follow."
|
||||
className="min-h-[280px] w-full resize-y rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-4 py-3 font-mono text-[12px] leading-5 text-[#D3D8E0] outline-none transition-colors focus:border-white/[0.16] disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={labelClass}>Who can use it</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ScopeButton
|
||||
value="personal"
|
||||
label="Personal"
|
||||
icon={<UserRound className="size-3.5" />}
|
||||
selected={draft.scope === "personal"}
|
||||
disabled={!canSelectScope("personal") || busy}
|
||||
onSelect={(scope) => {
|
||||
resetErrors()
|
||||
setDraft((current) => setSkillDraftScope(current, scope))
|
||||
}}
|
||||
/>
|
||||
{showSharedScopeOptions ? (
|
||||
{canManage ? (
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
<span className={labelClass}>Who can use it</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ScopeButton
|
||||
value="org"
|
||||
label="Org-wide"
|
||||
icon={<Building2 className="size-3.5" />}
|
||||
selected={draft.scope === "org"}
|
||||
disabled={!canSelectScope("org") || busy}
|
||||
value="personal"
|
||||
label="Personal"
|
||||
icon={<UserRound className="size-3.5" />}
|
||||
selected={draft.scope === "personal"}
|
||||
disabled={!canSelectScope("personal") || busy}
|
||||
onSelect={(scope) => {
|
||||
resetErrors()
|
||||
setDraft((current) => setSkillDraftScope(current, scope))
|
||||
}}
|
||||
/>
|
||||
{showSharedScopeOptions ? (
|
||||
<ScopeButton
|
||||
value="org"
|
||||
label="Org-wide"
|
||||
icon={<Building2 className="size-3.5" />}
|
||||
selected={draft.scope === "org"}
|
||||
disabled={!canSelectScope("org") || busy}
|
||||
onSelect={(scope) => {
|
||||
resetErrors()
|
||||
setDraft((current) => setSkillDraftScope(current, scope))
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{scopePermissionCopy ? (
|
||||
<p className="text-[11px] leading-5 text-[#687282]">
|
||||
{scopePermissionCopy}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{scopePermissionCopy ? (
|
||||
<p className="text-[11px] leading-5 text-[#687282]">
|
||||
{scopePermissionCopy}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{visibleError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-300"
|
||||
>
|
||||
{visibleError}
|
||||
</div>
|
||||
) : null}
|
||||
<label className="flex min-w-0 shrink-0 flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={labelClass}>Description</span>
|
||||
<span className="text-[10px] text-[#596270]">
|
||||
{draft.description.length} / {SKILL_DESCRIPTION_MAX_LENGTH}
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => set("description", event.target.value)}
|
||||
readOnly={!canManage}
|
||||
disabled={busy}
|
||||
maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
|
||||
rows={3}
|
||||
placeholder="When should the brain use this playbook?"
|
||||
className={cn(inputClass, "resize-y py-2 leading-5")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-white/[0.05] border-t pt-3">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={labelClass}>Instructions (Markdown)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
bodyBytes > SKILL_BODY_MAX_LENGTH
|
||||
? "text-red-300"
|
||||
: "text-[#596270]",
|
||||
)}
|
||||
>
|
||||
{Math.ceil(bodyBytes / 1024)} / 16 KB
|
||||
</span>
|
||||
{canManage ? (
|
||||
<div className="inline-flex rounded-full border border-white/[0.08] bg-[#0D0F14] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(false)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
!preview
|
||||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||||
: "text-[#687282]",
|
||||
)}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(true)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
preview
|
||||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||||
: "text-[#687282]",
|
||||
)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{preview || !canManage ? (
|
||||
<MarkdownPreview body={draft.body} />
|
||||
) : (
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => set("body", event.target.value)}
|
||||
disabled={busy}
|
||||
maxLength={SKILL_BODY_MAX_LENGTH}
|
||||
spellCheck={false}
|
||||
placeholder={
|
||||
"# Process\n\nDescribe the steps, format, and voice to follow."
|
||||
}
|
||||
className="min-h-[240px] w-full flex-1 resize-y rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-4 py-3 font-mono text-[12px] leading-5 text-[#D3D8E0] outline-none transition-colors focus:border-white/[0.16] disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="shrink-0 rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-300"
|
||||
>
|
||||
{visibleError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-white/[0.05] border-t px-5 py-4">
|
||||
<div>
|
||||
{canDelete ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import type { BrainSkill } from "@/hooks/use-brain-skills"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
function updatedLabel(updatedAt: number) {
|
||||
const date = new Date(updatedAt)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
}
|
||||
|
||||
function ScopeBadge({ scope }: { scope: BrainSkill["scope"] }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-[18px] shrink-0 items-center rounded-full px-1.5 text-[10px] font-medium uppercase tracking-[0.04em]",
|
||||
scope === "org"
|
||||
? "bg-[#2A3140] text-[#A9B4C6]"
|
||||
: "bg-white/[0.05] text-[#7E8794]",
|
||||
)}
|
||||
>
|
||||
{scope === "org" ? "Org-wide" : "Personal"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillRow({
|
||||
skill,
|
||||
onOpen,
|
||||
}: {
|
||||
skill: BrainSkill
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const updated = updatedLabel(skill.updatedAt)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label={`${skill.canEdit ? "Edit" : "View"} ${skill.name}`}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"group flex w-full items-center gap-4 px-4 py-3 text-left transition-colors hover:bg-white/[0.03]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="truncate text-[13px] font-semibold tracking-[-0.1px] text-[#FAFAFA]">
|
||||
{skill.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={skill.scope} />
|
||||
{skill.status === "disabled" ? (
|
||||
<span className="inline-flex h-[18px] shrink-0 items-center rounded-full border border-red-400/20 bg-red-400/[0.07] px-1.5 text-[10px] font-medium text-red-300">
|
||||
Disabled
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] leading-5 text-[#737B87]">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden shrink-0 text-[11px] tabular-nums text-[#596270] sm:block">
|
||||
v{skill.version}
|
||||
{updated ? ` · ${updated}` : ""}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-[#4A5260] transition-colors group-hover:text-[#8B929E]" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue