256 lines
7.7 KiB
Vue
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>
|