<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>