Newer
Older
navi-1 / webclient / src / components / settings / SkillsCatalogPanel.vue
<template>
  <div class="catalog-panel">
    <div class="catalog-toolbar">
      <div class="catalog-search">
        <GnSearchField
          v-model="query"
          :placeholder="searchPlaceholder"
          :aria-label="searchPlaceholder"
        />
      </div>
      <div class="catalog-area">
        <GnSelect
          v-model="areaFilter"
          :aria-label="t('settings.skillsFilterArea')"
          :options="areaOptions"
        />
      </div>
      <GnButton variant="primary" size="sm" icon="ph ph-plus" @click="startCreate">
        {{ addLabel }}
      </GnButton>
    </div>

    <GnAlert v-if="loadError" variant="error" class="catalog-alert">{{ loadError }}</GnAlert>

    <div class="catalog-layout" :class="{ 'is-detail-open': detailOpen }">
      <aside class="catalog-list" :aria-label="t('settings.skillsCatalog')">
        <div v-if="loading" class="catalog-loading">
          <GnLoader circle :label="t('settings.skillsLoading')" />
        </div>

        <div v-else-if="!items.length" class="catalog-empty">
          <i class="ph ph-notebook" aria-hidden="true"></i>
          <p>{{ emptyLabel }}</p>
        </div>

        <button
          v-for="item in items"
          :key="item.id"
          type="button"
          class="catalog-row"
          :class="{ 'is-active': item.id === selectedId }"
          @click="select(item.id)"
        >
          <span class="catalog-row-head">
            <span class="catalog-row-name">{{ item.name }}</span>
            <span class="catalog-row-version">v{{ item.version }}</span>
          </span>
          <span class="catalog-row-meta">
            {{ areaLabel(item.area) }}
            <template v-if="item.visibility === 'shared'">
              · {{ t('settings.skillsShared') }}
            </template>
          </span>
          <span v-if="item.description" class="catalog-row-description">
            {{ item.description }}
          </span>
        </button>
      </aside>

      <section class="catalog-detail">
        <button type="button" class="catalog-back" @click="reset">
          <i class="ph ph-arrow-left" aria-hidden="true"></i>
          <span>{{ t('settings.skillsBack') }}</span>
        </button>

        <!-- Editing: the document is YAML front matter plus markdown, so the
             only honest editor is the source itself. -->
        <template v-if="editing">
          <header class="catalog-detail-head">
            <div class="catalog-detail-title">
              <h3>{{ entry ? entry.name : newTitle }}</h3>
              <span v-if="entry" class="catalog-detail-version">v{{ entry.version }}</span>
            </div>
            <span v-if="entry && !entry.can_edit" class="catalog-readonly">
              {{ t('settings.skillsReadOnly') }}
            </span>
          </header>

          <GnAlert v-if="saveError" variant="error" class="catalog-save-error">
            {{ saveError }}
          </GnAlert>

          <GnTextarea
            v-model="draft"
            class="catalog-textarea"
            :rows="18"
            :disabled="!canEdit || saving"
            :placeholder="t('settings.skillsEditorPlaceholder')"
            :aria-label="t('settings.skillsEditorLabel')"
          />

          <div class="catalog-editor-actions">
            <div v-if="authStore.isAdmin && canEdit" class="catalog-visibility">
              <GnSelect
                v-model="visibility"
                :aria-label="t('settings.skillsVisibility')"
                :options="visibilityOptions"
              />
            </div>
            <div class="catalog-editor-buttons">
              <GnButton
                variant="primary"
                :disabled="!canEdit || saving || !draft.trim()"
                @click="save"
              >
                {{ t('common.save') }}
              </GnButton>
              <GnButton variant="ghost" :disabled="saving" @click="cancel">
                {{ t('common.cancel') }}
              </GnButton>
              <GnButton
                v-if="entry && entry.can_edit"
                variant="ghost"
                icon="ph ph-trash"
                :disabled="saving"
                @click="remove"
              >
                {{ t('common.delete') }}
              </GnButton>
            </div>
          </div>
        </template>

        <!-- Reading: the parsed document, the way the agent loads it. -->
        <template v-else-if="entry">
          <header class="catalog-detail-head">
            <div class="catalog-detail-title">
              <h3>{{ entry.name }}</h3>
              <span class="catalog-detail-version">v{{ entry.version }}</span>
            </div>
            <div class="catalog-detail-actions">
              <span v-if="!entry.can_edit" class="catalog-readonly">
                {{ t('settings.skillsReadOnly') }}
              </span>
              <GnButton
                v-if="entry.can_edit"
                variant="secondary"
                size="sm"
                icon="ph ph-pencil-simple"
                class="catalog-edit-btn"
                @click="beginEdit"
              >
                {{ t('settings.skillsEdit') }}
              </GnButton>
              <GnButton
                v-if="entry.can_edit"
                variant="ghost"
                size="sm"
                icon="ph ph-trash"
                class="catalog-delete-btn"
                @click="remove"
              >
                {{ t('common.delete') }}
              </GnButton>
            </div>
          </header>

          <p v-if="entry.description" class="catalog-description">{{ entry.description }}</p>

          <div class="catalog-meta">
            <span>{{ areaLabel(entry.area) }}</span>
            <span>{{ entry.visibility === 'shared' ? t('settings.skillsShared') : t('settings.skillsPersonal') }}</span>
            <span v-if="entry.updated_at">
              {{ t('settings.skillsUpdated', { when: formatWhen(entry.updated_at) }) }}
            </span>
            <span v-if="entry.tags?.length" class="catalog-tags">
              <i class="ph ph-tag" aria-hidden="true"></i>
              {{ entry.tags.join(', ') }}
            </span>
          </div>

          <SkillDocument v-if="entry.document" :doc="entry.document" class="catalog-document" />

          <template v-else>
            <GnAlert variant="warning" class="catalog-parse-error">
              {{ t('settings.skillsParseFailed') }}
            </GnAlert>
            <pre class="catalog-raw">{{ entry.content }}</pre>
          </template>
        </template>

        <div v-else class="catalog-placeholder">
          <i class="ph ph-notebook" aria-hidden="true"></i>
          <p>{{ t('settings.skillsPlaceholder') }}</p>
        </div>

        <div v-if="entry && !editing" class="catalog-history">
          <button type="button" class="catalog-history-toggle" @click="toggleHistory">
            <i class="ph" :class="showHistory ? 'ph-caret-down' : 'ph-caret-right'" aria-hidden="true"></i>
            {{ t('settings.skillsHistory') }}
          </button>
          <div v-if="showHistory" class="catalog-history-list">
            <div v-if="!versions.length" class="catalog-history-empty">
              {{ t('settings.skillsHistoryEmpty') }}
            </div>
            <div v-for="v in versions" :key="v.version" class="catalog-history-item">
              <div class="catalog-history-meta">
                v{{ v.version }} · {{ v.edited_by || t('settings.skillsUnknownEditor') }}
                · {{ formatWhen(v.created_at) }}
                <template v-if="v.reason"> · {{ v.reason }}</template>
              </div>
              <pre class="catalog-history-text">{{ v.content }}</pre>
            </div>
          </div>
        </div>
      </section>
    </div>
  </div>
</template>

<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
  GnAlert,
  GnButton,
  GnLoader,
  GnSearchField,
  GnSelect,
  GnTextarea,
  useToast,
} from 'gnexus-ui-kit/vue'
import { useAuthStore } from '@/stores/auth.js'
import { useConfirm } from '@/composables/useConfirm.js'
import { locale, t } from '@/i18n/index.js'
import SkillDocument from '@/components/skills/SkillDocument.vue'
import * as api from '@/api/index.js'

// One catalog, two sections: Settings shows skills and protocols as separate
// tabs and mounts this panel with the kind it owns.
const props = defineProps({
  kind: {
    type: String,
    default: 'skill',
    validator: value => value === 'skill' || value === 'protocol',
  },
})

const authStore = useAuthStore()
const confirm = useConfirm()
const toast = useToast()

const AREA_SLUGS = {
  home: 'settings.skillsAreaHome',
  infrastructure: 'settings.skillsAreaInfrastructure',
  workflows: 'settings.skillsAreaWorkflows',
  general: 'settings.skillsAreaGeneral',
}

const TEMPLATES = {
  skill: `---
kind: skill
name:
description:
area: general
tags: []
triggers: []
---

`,
  protocol: `---
kind: protocol
name:
description:
area: general
tags: []
triggers: []
preconditions: []
steps:
  -
post_checks:
  -
on_deviation:
on_blocked:
on_already_done:
---

`,
}

const items = ref([])
const loading = ref(false)
const loadError = ref('')

const query = ref('')
const areaFilter = ref('')

const entry = ref(null)
const selectedId = ref(null)
const editing = ref(false)
const draft = ref('')
const visibility = ref('personal')
const saving = ref(false)
const saveError = ref('')

const versions = ref([])
const showHistory = ref(false)

const isProtocol = computed(() => props.kind === 'protocol')
const searchPlaceholder = computed(() =>
  t(isProtocol.value ? 'settings.skillsSearchProtocols' : 'settings.skillsSearchSkills')
)
const addLabel = computed(() =>
  t(isProtocol.value ? 'settings.skillsAddProtocol' : 'settings.skillsAddSkill')
)
const emptyLabel = computed(() =>
  t(isProtocol.value ? 'settings.skillsEmptyProtocols' : 'settings.skillsEmptySkills')
)
const newTitle = computed(() =>
  t(isProtocol.value ? 'settings.skillsNewProtocol' : 'settings.skillsNewSkill')
)
// A selected entry or a new one takes the detail column; on a phone that also
// replaces the list, so the reader gets the whole width.
const detailOpen = computed(() => editing.value || entry.value !== null)
const canEdit = computed(() => (editing.value && !entry.value ? true : Boolean(entry.value?.can_edit)))

const areaOptions = computed(() => [
  { value: '', label: t('settings.skillsFilterAreaAll') },
  ...Object.entries(AREA_SLUGS).map(([value, slug]) => ({ value, label: t(slug) })),
])
const visibilityOptions = computed(() => [
  { value: 'personal', label: t('settings.skillsPersonal') },
  { value: 'shared', label: t('settings.skillsShared') },
])

let debounce = null

watch([query, areaFilter], () => {
  clearTimeout(debounce)
  debounce = setTimeout(load, 250)
})

watch(() => props.kind, () => {
  reset()
  load()
})

onMounted(load)
onBeforeUnmount(() => clearTimeout(debounce))

async function load() {
  loading.value = true
  loadError.value = ''
  try {
    const data = await api.getSkills({
      q: query.value.trim() || null,
      area: areaFilter.value || null,
      kind: props.kind,
    })
    items.value = data.items || []
  } catch (err) {
    loadError.value = errorText(err)
  } finally {
    loading.value = false
  }
}

async function select(id) {
  if (saving.value) return
  selectedId.value = id
  editing.value = false
  showHistory.value = false
  saveError.value = ''
  try {
    const data = await api.getSkill(id)
    entry.value = data
    draft.value = data.content
    visibility.value = data.visibility
  } catch (err) {
    loadError.value = errorText(err)
  }
}

function startCreate() {
  entry.value = null
  selectedId.value = null
  editing.value = true
  draft.value = TEMPLATES[props.kind]
  visibility.value = 'personal'
  versions.value = []
  showHistory.value = false
  saveError.value = ''
}

function beginEdit() {
  if (!entry.value) return
  draft.value = entry.value.content
  visibility.value = entry.value.visibility
  saveError.value = ''
  editing.value = true
}

function cancel() {
  if (entry.value) {
    editorBackToRead()
  } else {
    reset()
  }
}

function editorBackToRead() {
  draft.value = entry.value?.content || ''
  visibility.value = entry.value?.visibility || 'personal'
  saveError.value = ''
  editing.value = false
}

function reset() {
  entry.value = null
  selectedId.value = null
  editing.value = false
  draft.value = ''
  saveError.value = ''
  versions.value = []
  showHistory.value = false
}

async function save() {
  saving.value = true
  saveError.value = ''
  try {
    if (entry.value) {
      const payload = { content: draft.value }
      if (authStore.isAdmin && visibility.value !== entry.value.visibility) {
        payload.visibility = visibility.value
      }
      const updated = await api.updateSkill(entry.value.id, payload)
      entry.value = updated
      draft.value = updated.content
      visibility.value = updated.visibility
      toast.success({ title: t('settings.skillsSaved') })
    } else {
      const created = await api.createSkill({
        content: draft.value,
        visibility: authStore.isAdmin ? visibility.value : 'personal',
      })
      entry.value = created
      selectedId.value = created.id
      draft.value = created.content
      visibility.value = created.visibility
      toast.success({ title: t('settings.skillsCreated') })
    }
    editing.value = false
    await load()
  } catch (err) {
    // The server names what is missing in a protocol; that message is the
    // whole point of validating on save, so show it verbatim.
    saveError.value = errorText(err)
  } finally {
    saving.value = false
  }
}

async function remove() {
  if (!entry.value) return
  const ok = await confirm(t('settings.skillsDeleteConfirm', { name: entry.value.name }))
  if (!ok) return
  try {
    await api.deleteSkill(entry.value.id)
    toast.success({ title: t('settings.skillsDeleted') })
    reset()
    await load()
  } catch (err) {
    toast.error({ title: t('common.error'), text: errorText(err) })
  }
}

async function toggleHistory() {
  showHistory.value = !showHistory.value
  if (!showHistory.value || versions.value.length || !entry.value) return
  try {
    const data = await api.getSkillVersions(entry.value.id)
    versions.value = data.items || []
  } catch (err) {
    saveError.value = errorText(err)
  }
}

function areaLabel(area) {
  return t(AREA_SLUGS[area] || AREA_SLUGS.general)
}

function formatWhen(iso) {
  if (!iso) return ''
  const d = new Date(iso)
  return d.toLocaleString(locale.value, {
    year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
  })
}

// api/index.js throws `METHOD path → status: body`; the body is FastAPI's JSON,
// whose `detail` is the sentence worth showing.
function errorText(err) {
  const raw = String(err?.message || err)
  const match = /→\s*\d+:\s*([\s\S]*)$/.exec(raw)
  const body = match ? match[1] : raw
  try {
    const parsed = JSON.parse(body)
    return parsed.detail || body
  } catch {
    return body
  }
}
</script>

<style scoped lang="scss">
@use 'kit-deps' as *;

.catalog-panel {
  display: flex;
  flex-direction: column;
  gap: $space-sm;
  min-height: 0;
}

/* ---------- toolbar ---------- */

.catalog-toolbar {
  display: flex;
  align-items: center;
  gap: $space-sm;
  flex-wrap: wrap;

  // The kit's .form-group wrapper carries its own bottom margin; the row's
  // gap already separates the fields.
  :deep(.form-group) {
    margin-bottom: 0;
  }
}

.catalog-search {
  flex: 1 1 220px;
  min-width: 0;
}

.catalog-area {
  flex: 0 0 180px;
}

.catalog-alert {
  margin: 0;
}

/* ---------- two panes ---------- */

// The list is a column of compact rows and scrolls on its own; the detail is
// the reading column. On a phone only one of the two is on screen.
.catalog-layout {
  display: grid;
  grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
  gap: $space-md;
  align-items: start;

  @media (max-width: 767px) {
    grid-template-columns: 1fr;

    .catalog-detail {
      display: none;
    }

    &.is-detail-open {
      .catalog-list {
        display: none;
      }

      .catalog-detail {
        display: flex;
      }
    }
  }
}

.catalog-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  max-height: 70vh;
  overflow-y: auto;
}

.catalog-loading {
  padding: 24px;
  text-align: center;
}

.catalog-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 28px 16px;
  border: 1px dashed var(--gn-color-border);
  border-radius: $border-radius-md;
  text-align: center;

  i {
    font-size: 1.75rem;
    color: var(--gn-color-text-dimmed);
  }

  p {
    margin: 0;
    font-size: 0.85rem;
    color: var(--gn-color-text-medium);
  }
}

/* ---------- list rows ---------- */

.catalog-row {
  display: flex;
  flex-direction: column;
  gap: 1px;
  width: 100%;
  min-width: 0;
  padding: 7px 10px;
  border: 1px solid transparent;
  border-radius: $border-radius-md;
  background: none;
  color: inherit;
  text-align: left;
  cursor: pointer;
  font: inherit;

  &:hover {
    background: var(--gn-color-surface);
    border-color: var(--gn-color-border);
  }

  &.is-active {
    background: var(--gn-color-surface);
    border-color: var(--gn-color-primary);
  }
}

.catalog-row-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: $space-xs;
}

.catalog-row-name {
  font-size: 0.875rem;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.catalog-row-version {
  flex: none;
  font-size: 0.7rem;
  color: var(--gn-color-text-dimmed);
}

.catalog-row-meta {
  font-size: 0.72rem;
  color: var(--gn-color-text-dimmed);
}

.catalog-row-description {
  font-size: 0.78rem;
  color: var(--gn-color-text-medium);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---------- detail ---------- */

.catalog-detail {
  display: flex;
  flex-direction: column;
  gap: $space-sm;
  min-width: 0;
}

.catalog-back {
  display: none;
  align-items: center;
  gap: 6px;
  padding: 0;
  border: none;
  background: none;
  color: var(--gn-color-text-medium);
  font: inherit;
  font-size: 0.85rem;
  cursor: pointer;

  @media (max-width: 767px) {
    display: inline-flex;
  }
}

.catalog-detail-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: $space-sm;
  flex-wrap: wrap;
}

.catalog-detail-title {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-width: 0;

  h3 {
    margin: 0;
    font-size: 1rem;
    font-weight: 600;
    overflow-wrap: anywhere;
  }
}

.catalog-detail-version {
  font-size: 0.75rem;
  color: var(--gn-color-text-dimmed);
}

.catalog-detail-actions {
  display: flex;
  align-items: center;
  gap: $space-xs;
  flex-wrap: wrap;
}

.catalog-readonly {
  font-size: 0.78rem;
  color: var(--gn-color-text-dimmed);
}

.catalog-description {
  margin: 0;
  font-size: 0.875rem;
  color: var(--gn-color-text-medium);
}

.catalog-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px 10px;
  font-size: 0.75rem;
  color: var(--gn-color-text-dimmed);
}

.catalog-tags {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.catalog-document {
  padding-top: $space-xs;
}

.catalog-parse-error {
  margin: 0;
}

.catalog-raw,
.catalog-history-text {
  margin: 0;
  max-height: 320px;
  overflow: auto;
  padding: 10px 12px;
  border: 1px solid var(--gn-color-border);
  border-radius: $border-radius-md;
  background: var(--gn-color-surface);
  font-family: var(--gn-font-mono, monospace);
  font-size: 0.75rem;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* ---------- editor ---------- */

// The kit pins a textarea to a control height and forbids resizing; a document
// editor needs the room of a page. The element part of the selector outranks
// the kit rules; `:deep` because the class lands on GnTextarea's <textarea>.
.catalog-detail :deep(textarea.catalog-textarea) {
  width: 100%;
  height: auto;
  min-height: 320px;
  resize: vertical;
  font-family: var(--gn-font-mono, monospace);
  font-size: 0.8rem;
  line-height: 1.5;
}

.catalog-editor-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: $space-sm;
  flex-wrap: wrap;

  :deep(.form-group) {
    margin-bottom: 0;
  }
}

.catalog-visibility {
  flex: 0 0 180px;
}

.catalog-editor-buttons {
  display: flex;
  gap: $space-xs;
  flex-wrap: wrap;
}

/* ---------- history ---------- */

.catalog-history {
  border-top: 1px solid var(--gn-color-border);
  padding-top: $space-sm;
}

.catalog-history-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 0;
  border: none;
  background: none;
  color: var(--gn-color-text-medium);
  font: inherit;
  font-size: 0.85rem;
  cursor: pointer;
}

.catalog-history-list {
  display: flex;
  flex-direction: column;
  gap: $space-sm;
  margin-top: $space-sm;
}

.catalog-history-empty {
  font-size: 0.85rem;
  color: var(--gn-color-text-dimmed);
}

.catalog-history-item {
  border: 1px solid var(--gn-color-border);
  border-radius: $border-radius-md;
  padding: 8px 10px;
}

.catalog-history-meta {
  margin-bottom: 6px;
  font-size: 0.78rem;
  color: var(--gn-color-text-dimmed);
}

.catalog-history-text {
  max-height: 220px;
}

/* ---------- placeholder ---------- */

.catalog-placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 220px;
  border: 1px dashed var(--gn-color-border);
  border-radius: $border-radius-md;
  text-align: center;
  color: var(--gn-color-text-dimmed);

  i {
    font-size: 2rem;
  }

  p {
    margin: 0;
    max-width: 44ch;
    font-size: 0.85rem;
  }
}
</style>