feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles
This commit is contained in:
1 parent
339e646eb8
commit
45fbbc33b1
93 files changed
+7630
-294
No files matched your search
@@ -0,0 +1,255 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user