Files
RenewCMS/web/src/components/EditorConflictDialog.vue
T

256 lines
7.7 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { SHEET_CONTENT_CLASS } from '@/components/sheetClasses'
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
import LocaleButton from '@/components/localization/LocaleButton.vue'
import type { Article } from '@/composables/useArticles'
import type { SaveOutcome } from '@/composables/useArticleDraft'
import { clockOf } from '@/lib/clockTime'
export type ConflictResult = 'keep' | 'saved' | 'reloaded' | 'gone' | 'unauthorized' | 'forbidden'
const props = defineProps<{
theirs: Article
restoredAt: Date | null
fetchTheirs: () => Promise<Article | null>
attemptSave: (revision: number | undefined) => Promise<{ outcome: SaveOutcome; status: number }>
download: () => void
applyTheirs: (theirs: Article) => void
}>()
const emit = defineEmits<{ close: [result: ConflictResult] }>()
type Step = 'choose' | 'confirmReload'
type Failure = 'again' | 'failed'
const CLOSE_BUTTON = {
color: 'neutral',
variant: 'ghost',
class: 'size-11 justify-center p-0 text-muted md:size-7',
} as const
const FORBIDDEN_STATUS = 403
const shown = ref(props.theirs)
const step = ref<Step>('choose')
const failure = ref<Failure | null>(null)
const pending = ref(false)
const keepButton = ref<{ $el: HTMLElement } | null>(null)
const time = computed(() => clockOf(new Date(shown.value.updated_at)))
const title = computed(() =>
step.value === 'choose' ? t('Someone else saved this version') : t('Reload theirs?'),
)
const description = computed(() =>
step.value === 'choose'
? t(
"Someone else saved this version at {0}. Your changes aren't saved. They stay here until you choose.",
[time.value],
)
: t('This discards your unsaved changes.'),
)
const failureAlert = computed(() => {
if (failure.value === 'again') {
return {
title: t("Couldn't replace theirs"),
description: t(
'Someone else saved this version again at {0}, before your save went through. Nothing was overwritten. Your changes are still here.',
[time.value],
),
}
}
if (failure.value === 'failed') {
return {
title: t("Couldn't replace theirs"),
description: t('Nothing was overwritten. Your changes are still here. Try again.'),
}
}
return null
})
const openOptions = {
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
nextTick(() => keepButton.value?.$el?.focus())
},
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}
async function saveOver() {
pending.value = true
failure.value = null
try {
const { outcome, status } = await props.attemptSave(shown.value.revision)
if (outcome === 'saved') {
emit('close', 'saved')
} else if (outcome === 'conflict') {
const newer = await props.fetchTheirs()
if (newer) {
shown.value = newer
}
failure.value = newer ? 'again' : 'failed'
} else if (outcome === 'not-found') {
emit('close', 'gone')
} else if (outcome === 'unauthorized') {
emit('close', 'unauthorized')
} else if (status === FORBIDDEN_STATUS) {
emit('close', 'forbidden')
} else {
failure.value = 'failed'
}
} finally {
pending.value = false
}
}
function reload() {
props.applyTheirs(shown.value)
emit('close', 'reloaded')
}
</script>
<template>
<UModal
:open="true"
:title="title"
:description="description"
:dismissible="!pending"
:close="pending ? false : CLOSE_BUTTON"
:content="openOptions"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-15 sm:max-w-140`,
close: 'end-6 top-8 md:top-5',
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
description: 'mt-1 pe-14 text-sm text-pretty text-toned md:pe-10',
body: 'px-6 pt-4 pb-10 sm:px-6 sm:pt-4 sm:pb-10',
}"
@update:open="(value: boolean) => !value && !pending && emit('close', 'keep')"
>
<template #body>
<div class="flex flex-col gap-3.5">
<LocaleAlert
v-if="failureAlert"
tone="error"
icon="i-lucide-circle-x"
:title="failureAlert.title"
:description="failureAlert.description"
/>
<LocaleAlert
v-else-if="restoredAt && step === 'choose'"
tone="info"
icon="i-lucide-info"
:title="t('Restored unsaved changes from {0}', [clockOf(restoredAt)])"
:description="t('Your session ended. These changes were kept in this browser.')"
/>
<div class="flex flex-col gap-3.5">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Keep my changes')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('Nothing is sent. Your changes stay in the editor.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton
ref="keepButton"
kind="primary"
block
:disabled="pending"
@click="emit('close', 'keep')"
>
{{ t('Keep my changes') }}
</LocaleButton>
</div>
</div>
<template v-if="step === 'choose'">
<div
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Download my version')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('Saves your title and body as a text file. Nothing is sent.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton block :disabled="pending" @click="download()">
{{ t('Download my version') }}
</LocaleButton>
</div>
</div>
<div
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Reload theirs')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('Shows their version. You confirm first.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton
block
:disabled="pending"
@click="step = 'confirmReload'"
>
{{ t('Reload theirs') }}
</LocaleButton>
</div>
</div>
<div
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Save over theirs')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t(
'Replaces the title, body and search fields of this version only. Other languages are untouched.',
)
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton
block
:disabled="pending"
:loading="pending"
@click="saveOver"
>
{{ t('Save over theirs') }}
</LocaleButton>
</div>
</div>
</template>
<div
v-else
class="flex flex-col gap-2 border-t border-default pt-3.5 sm:flex-row sm:items-center sm:gap-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="font-semibold text-highlighted">{{
t('Reload theirs')
}}</span>
<span class="text-sm/4.75 text-pretty text-muted">{{
t('This discards your unsaved changes.')
}}</span>
</div>
<div class="flex sm:w-50 sm:shrink-0">
<LocaleButton block @click="reload">
{{ t('Reload and discard my changes') }}
</LocaleButton>
</div>
</div>
</div>
</div>
</template>
</UModal>
</template>