Newer
Older
navi-1 / webclient / src / components / settings / SkillsCatalogPanel.vue
<template>
  <div class="catalog-panel">
    <header class="catalog-header">
      <h2 class="catalog-title">{{ title }}</h2>
      <p class="catalog-hint">{{ hint }}</p>
    </header>

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

    <div class="catalog-layout" :class="{ 'is-detail-open': detailOpen }">
      <aside class="catalog-pane catalog-list-pane" :aria-label="t('settings.skillsCatalog')">
        <!-- The controls live with the list they act on, so what they filter
             is never in doubt. -->
        <div class="catalog-list-head">
          <GnSearchField
            v-model="query"
            :placeholder="t('settings.skillsSearchHint')"
            :aria-label="t('settings.skillsSearchLabel')"
          />
          <div class="catalog-filters">
            <GnSelect
              v-model="areaFilter"
              class="catalog-area"
              :aria-label="t('settings.skillsFilterArea')"
              :options="areaOptions"
            />
            <GnButton
              variant="primary"
              size="sm"
              icon="ph ph-plus"
              class="catalog-add"
              @click="startCreate"
            >
              {{ addLabel }}
            </GnButton>
          </div>
          <p v-if="!loading" class="catalog-count">{{ countLabel }}</p>
        </div>

        <div class="catalog-list">
          <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-main">
              <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>
            </span>
            <i class="ph ph-caret-right catalog-row-open" aria-hidden="true"></i>
          </button>
        </div>
      </aside>

      <section class="catalog-pane 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>
          </header>

          <div class="catalog-detail-body">
            <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>
          </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">
              <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>
              <GnIconButton
                v-if="entry.can_edit"
                icon="ph ph-trash"
                :label="t('common.delete')"
                size="sm"
                class="catalog-delete-btn"
                @click="remove"
              />
            </div>
          </header>

          <div class="catalog-detail-body">
            <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.can_edit" class="catalog-readonly">
                {{ t('settings.skillsReadOnly') }}
              </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>

            <div 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>
          </div>
        </template>

        <div v-else-if="detailLoading" class="catalog-detail-body catalog-detail-loading">
          <GnLoader circle :label="t('settings.skillsLoading')" />
        </div>

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

<script setup>
import { onMounted, watch } from 'vue'
import {
  GnAlert,
  GnButton,
  GnIconButton,
  GnLoader,
  GnSearchField,
  GnSelect,
  GnTextarea,
} from 'gnexus-ui-kit/vue'
import { t } from '@/i18n/index.js'
import SkillDocument from '@/components/skills/SkillDocument.vue'

import { areaLabel } from './skills/constants.js'
import { useCatalogEntry } from './skills/composables/useCatalogEntry.js'
import { useCatalogLabels } from './skills/composables/useCatalogLabels.js'
import { useCatalogList } from './skills/composables/useCatalogList.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 getKind = () => props.kind

const {
  items,
  loading,
  loadError,
  query,
  areaFilter,
  areaOptions,
  load,
} = useCatalogList({ getKind })

const {
  title,
  hint,
  addLabel,
  emptyLabel,
  countLabel,
  newTitle,
} = useCatalogLabels({ getKind, items })

const {
  authStore,
  entry,
  selectedId,
  detailLoading,
  editing,
  draft,
  visibility,
  saving,
  saveError,
  versions,
  showHistory,
  detailOpen,
  canEdit,
  visibilityOptions,
  select,
  startCreate,
  beginEdit,
  cancel,
  reset,
  save,
  remove,
  toggleHistory,
  formatWhen,
} = useCatalogEntry({ getKind, reload: load, loadError })

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

onMounted(load)
</script>

<style scoped lang="scss">
@use 'kit-deps' as *;
@use './skills/styles/panel' as *;
@use './skills/styles/layout' as *;
@use './skills/styles/rows' as *;
@use './skills/styles/detail' as *;
@use './skills/styles/editor' as *;
@use './skills/styles/history' as *;
@use './skills/styles/placeholder' as *;
</style>