Newer
Older
navi-1 / webclient / src / components / settings / SynapseReactionsPanel.vue
<template>
  <div class="reactions-panel">
    <h2 class="reactions-title">{{ t('settings.reactionsTitle') }}</h2>

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

    <GnAlert
      v-if="ready && !settings?.source_ready"
      variant="info"
      class="reactions-alert"
    >
      <i class="ph ph-info"></i>
      <span>{{ t('settings.reactionsNotConfigured') }}</span>
    </GnAlert>

    <div v-if="store.loading && !settings" class="reactions-loading">
      <GnLoader circle :label="t('settings.reactionsLoading')" />
    </div>

    <template v-else-if="settings">
      <div class="reactions-row">
        <div class="reactions-row-text">
          <div class="reactions-row-title">{{ t('settings.reactionsEnable') }}</div>
          <div class="reactions-row-status">
            {{ t(settings.reactions_enabled ? 'settings.reactionsOn' : 'settings.reactionsOff') }}
          </div>
        </div>
        <GnSwitch
          :model-value="settings.reactions_enabled"
          :disabled="store.saving"
          @update:model-value="patch({ reactions_enabled: $event })"
        />
      </div>

      <div class="reactions-row">
        <div class="reactions-row-text">
          <div class="reactions-row-title">{{ t('settings.reactionsNotify') }}</div>
          <div class="reactions-row-status">
            {{ COMPLETION_LABELS[settings.completion_notify] }}
          </div>
        </div>
        <GnRadioGroup
          :model-value="settings.completion_notify"
          name="completion-notify"
          :options="COMPLETION_OPTIONS"
          @update:model-value="patch({ completion_notify: $event })"
        />
      </div>

      <div class="reactions-row">
        <div class="reactions-row-text">
          <div class="reactions-row-title">{{ t('settings.reactionsTarget') }}</div>
          <div class="reactions-row-status">{{ t('settings.reactionsTargetNote') }}</div>
        </div>
        <GnRadioGroup
          :model-value="settings.push_target"
          name="push-target"
          :options="PUSH_TARGET_OPTIONS"
          @update:model-value="patch({ push_target: $event })"
        />
      </div>

      <div class="reactions-row reactions-row--stacked">
        <div class="reactions-row-text">
          <div class="reactions-row-title">{{ t('settings.reactionsSessionTtl') }}</div>
          <div class="reactions-row-status">{{ t('settings.reactionsSessionTtlNote') }}</div>
        </div>
        <GnInput
          :model-value="ttlDraft"
          type="number"
          min="0"
          max="43200"
          step="60"
          class="reactions-ttl-input"
          :help="t('settings.reactionsSessionTtlUnit')"
          @update:model-value="ttlDraft = $event"
          @change="saveTtl"
        />
      </div>

      <SynapseInstructionsField
        doc="reaction"
        :title="t('settings.reactionsInstructions')"
        :note="t('settings.reactionsInstructionsNote')"
        :placeholder="t('settings.reactionsInstructionsPlaceholder')"
        :save-label="t('settings.reactionsInstructionsSave')"
        :model-value="draft"
        :versions="store.versions"
        :dirty="instructionsDirty"
        :saving="store.saving"
        @update:model-value="draft = $event"
        @save="saveInstructions"
      />

      <SynapseInstructionsField
        doc="dispatcher"
        :title="t('settings.dispatcherInstructions')"
        :note="t('settings.dispatcherInstructionsNote')"
        :placeholder="t('settings.dispatcherInstructionsPlaceholder')"
        :save-label="t('settings.dispatcherInstructionsSave')"
        :model-value="dispatcherDraft"
        :versions="store.dispatcherVersions"
        :dirty="dispatcherDirty"
        :saving="store.saving"
        @update:model-value="dispatcherDraft = $event"
        @save="saveDispatcherInstructions"
      />
    </template>
  </div>
</template>

<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n/index.js'
import { useSynapseReactionsStore } from '@/stores/synapseReactions.js'
import { useToast } from 'gnexus-ui-kit/vue'
import {
  GnAlert,
  GnInput,
  GnLoader,
  GnRadioGroup,
  GnSwitch,
} from 'gnexus-ui-kit/vue'
import SynapseInstructionsField from '@/components/settings/SynapseInstructionsField.vue'

// Computed, the three of them: options and labels built once would keep the
// language the panel was opened in.
const COMPLETION_OPTIONS = computed(() => [
  { value: 'always', label: t('common.always') },
  { value: 'important', label: t('settings.reactionsOnlyFailures') },
  { value: 'never', label: t('common.never') },
])

// Distinct radio-group names: without them native exclusion either fails
// (slot radios get no name) or crosses the two groups (kit default name).
const PUSH_TARGET_OPTIONS = computed(() => [
  { value: 'app', label: t('settings.reactionsTargetApp') },
  { value: 'app_synapse', label: t('settings.reactionsTargetAppSynapse'), disabled: !settings.value?.source_ready },
  { value: 'synapse', label: t('settings.reactionsTargetSynapse'), disabled: !settings.value?.source_ready },
])

const COMPLETION_LABELS = computed(() => ({
  always: t('settings.reactionsPushAlways'),
  important: t('settings.reactionsPushFailures'),
  never: t('settings.reactionsPushNever'),
}))

const instructionsPlaceholder = computed(() => t('settings.reactionsInstructionsPlaceholder'))

const store = useSynapseReactionsStore()
const toast = useToast()

const settings = computed(() => store.settings)
const ready = ref(false)

// Two drafts, one per instruction document (reaction / dispatcher), each with
// its own history — the API keeps them apart by `doc`.
const draft = ref('')
const dispatcherDraft = ref('')
const ttlDraft = ref(0)

const instructionsDirty = computed(
  () => settings.value !== null && draft.value !== settings.value.instructions
)
const dispatcherDirty = computed(
  () => settings.value !== null && dispatcherDraft.value !== settings.value.dispatcher_instructions
)

watch(
  settings,
  (val) => {
    if (!val) return
    if (draft.value !== val.instructions) draft.value = val.instructions
    if (dispatcherDraft.value !== val.dispatcher_instructions) {
      dispatcherDraft.value = val.dispatcher_instructions
    }
    ttlDraft.value = val.reaction_session_ttl_minutes
  },
  { immediate: true }
)

onMounted(async () => {
  ready.value = true
  try {
    await store.fetchSettings()
    await Promise.all([store.fetchVersions(), store.fetchDispatcherVersions()])
  } catch (err) {
    toast.error({ title: t('toast.reactionsLoadFailed'), text: err.message })
  }
})

async function patch(partial) {
  try {
    await store.save(partial)
  } catch (err) {
    toast.error({ title: t('toast.saveFailed'), text: err.message })
  }
}

async function saveInstructions() {
  try {
    await store.save({ instructions: draft.value })
    await store.fetchVersions()
    toast.success({ title: t('toast.instructionsSaved') })
  } catch (err) {
    toast.error({ title: t('toast.instructionsFailed'), text: err.message })
  }
}

async function saveDispatcherInstructions() {
  try {
    await store.save({ dispatcher_instructions: dispatcherDraft.value })
    await store.fetchDispatcherVersions()
    toast.success({ title: t('toast.instructionsSaved') })
  } catch (err) {
    toast.error({ title: t('toast.instructionsFailed'), text: err.message })
  }
}

/** Committed on blur/Enter (`change`), not per keystroke: the input is a
 *  free-text number and a PUT per digit would spam the version history. */
async function saveTtl() {
  const value = Number(ttlDraft.value)
  if (!Number.isFinite(value) || value < 0) {
    ttlDraft.value = settings.value?.reaction_session_ttl_minutes ?? 0
    return
  }
  const clamped = Math.min(Math.round(value), 43200)
  if (clamped === settings.value?.reaction_session_ttl_minutes) return
  ttlDraft.value = clamped
  await patch({ reaction_session_ttl_minutes: clamped })
}
</script>

<style scoped lang="scss">
.reactions-panel {
  margin-bottom: 24px;
}

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

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

.reactions-alert {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin-bottom: 16px;

  i {
    font-size: 16px;
    flex-shrink: 0;
  }
}

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

.reactions-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 16px;
  margin-bottom: 12px;
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 8px;
}

.reactions-row-title {
  font-size: 0.9rem;
  color: var(--gn-color-text-medium);
  font-weight: 500;
}

.reactions-row-status {
  font-size: 0.8rem;
  color: var(--gn-color-text-dark);
  margin-top: 2px;
  max-width: 420px;
}

.reactions-ttl-input {
  width: 90px;
  flex: 0 0 auto;
}
</style>