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-instructions">
        <div class="reactions-row-title">{{ t('settings.reactionsInstructions') }}</div>
        <div class="reactions-row-status">{{ t('settings.reactionsInstructionsNote') }}</div>
        <GnTextarea
          v-model="draft"
          :rows="6"
          :placeholder="instructionsPlaceholder"
          class="reactions-instructions-textarea"
        />
        <div class="reactions-instructions-actions">
          <GnButton
            variant="primary"
            :disabled="!instructionsDirty || store.saving"
            @click="saveInstructions"
          >
            {{ t('settings.reactionsInstructionsSave') }}
          </GnButton>
        </div>

        <div
          v-if="store.versions.length"
          class="reactions-history"
        >
          <div class="reactions-history-title">{{ t('settings.reactionsHistory') }}</div>
          <div
            v-for="v in store.versions"
            :key="v.created_at + ':' + v.content.slice(0, 24)"
            class="reactions-history-item"
          >
            <span class="reactions-history-meta">
              <i
                class="ph"
                :class="v.edited_by === 'navi' ? 'ph-robot' : 'ph-user'"
              ></i>
              {{ v.edited_by === 'navi' ? 'Navi' : t('common.you') }}
              · {{ formatWhen(v.created_at) }}
              <template v-if="v.reason"> · {{ v.reason }}</template>
            </span>
            <div class="reactions-history-text">{{ v.content }}</div>
          </div>
        </div>
      </div>
    </template>
  </div>
</template>

<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { locale, t } from '@/i18n/index.js'
import { useSynapseReactionsStore } from '@/stores/synapseReactions.js'
import { useToast } from 'gnexus-ui-kit/vue'
import {
  GnAlert,
  GnButton,
  GnLoader,
  GnRadioGroup,
  GnSwitch,
  GnTextarea,
} from 'gnexus-ui-kit/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)

const draft = ref('')
const instructionsDirty = computed(
  () => settings.value !== null && draft.value !== settings.value.instructions
)

watch(
  settings,
  (val) => { if (val && draft.value !== val.instructions) draft.value = val.instructions },
  { immediate: true }
)

onMounted(async () => {
  ready.value = true
  try {
    await store.fetchSettings()
    await store.fetchVersions()
  } 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 })
  }
}

function formatWhen(iso) {
  const d = new Date(iso)
  return d.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' }) +
    ' ' + d.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' })
}
</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-instructions {
  padding: 14px 16px;
  margin-bottom: 12px;
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 8px;
}

.reactions-instructions-textarea {
  margin-top: 10px;
}

.reactions-instructions-actions {
  margin-top: 10px;
  display: flex;
  justify-content: flex-end;
}

.reactions-history {
  margin-top: 14px;
}

.reactions-history-title {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--gn-color-text-medium);
  margin-bottom: 8px;
}

.reactions-history-item {
  padding: 8px 10px;
  border-left: 2px solid var(--gn-border-color-muted);

  & + & {
    margin-top: 8px;
  }
}

.reactions-history-meta {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 0.75rem;
  color: var(--gn-color-text-dark);

  i {
    font-size: 12px;
  }
}

.reactions-history-text {
  margin-top: 4px;
  font-size: 0.8rem;
  color: var(--gn-color-text-medium);
  white-space: pre-wrap;
  word-break: break-word;
}
</style>