Newer
Older
navi-1 / webclient / src / components / settings / SkillsPanel.vue
<template>
  <div class="skills-panel">
    <div class="skills-header">
      <h2 class="skills-title">{{ t('settings.skillsTitle') }}</h2>
      <div class="skills-header-actions">
        <GnButton variant="primary" size="sm" icon="ph ph-plus" @click="startCreate('skill')">
          {{ t('settings.skillsAddSkill') }}
        </GnButton>
        <GnButton variant="secondary" size="sm" icon="ph ph-list-checks" @click="startCreate('protocol')">
          {{ t('settings.skillsAddProtocol') }}
        </GnButton>
      </div>
    </div>

    <p class="skills-description">{{ t('settings.skillsDescription') }}</p>

    <div class="skills-toolbar">
      <div class="skills-search">
        <GnSearchField
          v-model="query"
          :placeholder="t('settings.skillsSearchPlaceholder')"
          :aria-label="t('settings.skillsSearchPlaceholder')"
        />
      </div>
      <div class="skills-filter">
        <GnSelect
          v-model="kindFilter"
          :aria-label="t('settings.skillsFilterKind')"
          :options="kindOptions"
        />
      </div>
      <div class="skills-filter">
        <GnSelect
          v-model="areaFilter"
          :aria-label="t('settings.skillsFilterArea')"
          :options="areaOptions"
        />
      </div>
    </div>

    <div v-if="loadError" class="skills-error">
      <GnAlert variant="error">{{ loadError }}</GnAlert>
    </div>

    <div class="skills-layout">
      <aside class="skills-list" :aria-label="t('settings.skillsCatalog')">
        <div v-if="loading" class="skills-loading">
          <GnLoader circle :label="t('settings.skillsLoading')" />
        </div>

        <div v-else-if="!items.length" class="skills-empty">
          <i class="ph ph-notebook empty-icon" aria-hidden="true"></i>
          <p>{{ t('settings.skillsEmpty') }}</p>
        </div>

        <button
          v-for="item in items"
          :key="item.id"
          type="button"
          class="skills-row"
          :class="{ 'is-active': item.id === selectedId }"
          @click="select(item.id)"
        >
          <span class="skills-row-top">
            <span class="skills-row-name">{{ item.name }}</span>
            <span class="skills-badge" :data-kind="item.kind">
              {{ item.kind === 'protocol' ? t('settings.skillsKindProtocol') : t('settings.skillsKindSkill') }}
            </span>
          </span>
          <span v-if="item.description" class="skills-row-description">{{ item.description }}</span>
          <span class="skills-row-meta">
            {{ areaLabel(item.area) }}
            · {{ item.visibility === 'shared' ? t('settings.skillsShared') : t('settings.skillsPersonal') }}
            · v{{ item.version }}
          </span>
        </button>
      </aside>

      <section class="skills-editor">
        <template v-if="editing">
          <div class="skills-editor-head">
            <div class="skills-editor-title">
              {{ entry ? entry.name : t(isCreatingProtocol ? 'settings.skillsNewProtocol' : 'settings.skillsNewSkill') }}
              <span v-if="entry" class="skills-editor-version">v{{ entry.version }}</span>
            </div>
            <span v-if="entry && !entry.can_edit" class="skills-readonly">{{ t('settings.skillsReadOnly') }}</span>
          </div>

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

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

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

          <div v-if="entry" class="skills-history">
            <button type="button" class="skills-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="skills-history-list">
              <div v-if="!versions.length" class="skills-history-empty">
                {{ t('settings.skillsHistoryEmpty') }}
              </div>
              <div v-for="v in versions" :key="v.version" class="skills-history-item">
                <div class="skills-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="skills-history-text">{{ v.content }}</pre>
              </div>
            </div>
          </div>
        </template>

        <div v-else class="skills-placeholder">
          <i class="ph ph-notebook" aria-hidden="true"></i>
          <p>{{ t('settings.skillsPlaceholder') }}</p>
        </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 * as api from '@/api/index.js'

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 kindFilter = ref('')
const areaFilter = ref('')

const creating = ref(false)
const newKind = ref('skill')
const entry = ref(null)
const selectedId = ref(null)
const draft = ref('')
const visibility = ref('personal')
const saving = ref(false)
const saveError = ref('')

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

const editing = computed(() => creating.value || entry.value !== null)
const isCreatingProtocol = computed(() => creating.value && newKind.value === 'protocol')
const canEdit = computed(() => (creating.value ? true : Boolean(entry.value?.can_edit)))

// Computed, not module constants: labels go through t() and must follow a
// language switch while the panel is open.
const kindOptions = computed(() => [
  { value: '', label: t('settings.skillsFilterKindAll') },
  { value: 'skill', label: t('settings.skillsKindSkill') },
  { value: 'protocol', label: t('settings.skillsKindProtocol') },
])
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, kindFilter, areaFilter], () => {
  clearTimeout(debounce)
  debounce = setTimeout(load, 250)
})

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

async function load() {
  loading.value = true
  loadError.value = ''
  try {
    const data = await api.getSkills({
      q: query.value.trim() || null,
      kind: kindFilter.value || null,
      area: areaFilter.value || null,
    })
    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
  creating.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(kind) {
  entry.value = null
  selectedId.value = null
  creating.value = true
  newKind.value = kind
  draft.value = TEMPLATES[kind]
  visibility.value = 'personal'
  versions.value = []
  showHistory.value = false
  saveError.value = ''
}

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

async function save() {
  saving.value = true
  saveError.value = ''
  try {
    if (creating.value) {
      const created = await api.createSkill({
        content: draft.value,
        visibility: authStore.isAdmin ? visibility.value : 'personal',
      })
      entry.value = created
      selectedId.value = created.id
      creating.value = false
      draft.value = created.content
      visibility.value = created.visibility
      toast.success({ title: t('settings.skillsCreated') })
    } else {
      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') })
    }
    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 *;

.skills-panel {
  margin-bottom: 24px;
}

.skills-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: $space-sm;
  flex-wrap: wrap;
  margin-bottom: 8px;
}

.skills-title {
  font-size: 1.1rem;
  font-weight: 600;
  margin: 0;
  color: var(--gn-color-text-medium);
}

.skills-header-actions {
  display: flex;
  gap: $space-xs;
}

.skills-description {
  margin: 0 0 16px;
  font-size: 0.875rem;
  color: var(--gn-color-text-medium);
  max-width: 70ch;
}

.skills-toolbar {
  display: flex;
  gap: $space-sm;
  margin-bottom: $space-md;
  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;
  }
}

.skills-search {
  flex: 1 1 240px;
  min-width: 0;
}

.skills-filter {
  flex: 0 0 180px;
}

.skills-error {
  margin-bottom: $space-sm;
}

.skills-layout {
  display: grid;
  grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
  gap: $space-md;
  align-items: start;

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

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

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

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

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

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

.skills-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
  width: 100%;
  min-width: 0;
  padding: 10px 12px;
  border: 1px solid var(--gn-color-border);
  border-radius: var(--gn-radius-md);
  background: var(--gn-color-surface);
  color: inherit;
  text-align: left;
  cursor: pointer;
  font: inherit;

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

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

.skills-row-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: $space-xs;
}

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

.skills-badge {
  flex: none;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 1px 6px;
  border-radius: 999px;
  border: 1px solid var(--gn-color-border-strong);
  color: var(--gn-color-text-medium);

  &[data-kind='protocol'] {
    color: var(--gn-color-primary);
    border-color: var(--gn-color-primary);
  }
}

.skills-row-description {
  font-size: 0.8rem;
  color: var(--gn-color-text-medium);
  overflow-wrap: anywhere;
}

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

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

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

.skills-editor-title {
  font-size: 1rem;
  font-weight: 600;
  overflow-wrap: anywhere;
}

.skills-editor-version {
  margin-left: 6px;
  font-size: 0.8rem;
  font-weight: 400;
  color: var(--gn-color-text-dimmed);
}

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

// The kit pins a textarea to two control heights (`.form-group .label
// textarea.input` and `.form-group .label .input` both match it) and forbids
// resizing; a document editor needs the room of a page, not a form field. The
// element part of the selector is what outranks those kit rules. `:deep`
// because the class lands on GnTextarea's inner <textarea>, which carries the
// child's scope id, not this component's.
.skills-editor :deep(textarea.skills-textarea) {
  width: 100%;
  height: auto;
  min-height: 360px;
  resize: vertical;
  font-family: var(--gn-font-mono, monospace);
  font-size: 0.82rem;
  line-height: 1.5;
}

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

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

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

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

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

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

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

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

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

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

.skills-history-text {
  margin: 0;
  max-height: 220px;
  overflow: auto;
  font-family: var(--gn-font-mono, monospace);
  font-size: 0.75rem;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

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

  i {
    font-size: 2rem;
  }

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