refac: duplicates, crap, dry etc. on last features commits

This commit is contained in:
Florian Sylvain committed 2026-10-21 00:52:14 +02:00
1 parent fba20f83c5
commit 89051b3f1f
12 files changed
+260 -153

No files matched your search

+35 -17
View File
@@ -6,8 +6,26 @@ import type { FieldErrors } from '@/lib/schemaBuilderValidation'
type Builder = ReturnType<typeof useSchemaBuilder>
type InspectorActions = Pick<
Builder,
| 'setLabel'
| 'setFieldKey'
| 'unlockFieldKey'
| 'resetFieldKey'
| 'setFieldType'
| 'patchField'
| 'setLimit'
| 'setDefault'
| 'addChoice'
| 'removeChoice'
| 'renameChoice'
| 'reorderChoices'
>
const props = defineProps<{
builder: Builder
actions: InspectorActions
saving: boolean
focusToken: number
field: BuilderField
errors: FieldErrors
compact: boolean
@@ -20,22 +38,22 @@ const uid = () => props.field.uid
<FieldInspector
:field="field"
:errors="errors"
:disabled="builder.saving.value"
:disabled="saving"
:compact="compact"
:focus-token="builder.labelFocusToken.value"
@label="builder.setLabel(uid(), $event)"
@key="builder.setFieldKey(uid(), $event)"
@unlock="builder.unlockFieldKey(uid())"
@reset="builder.resetFieldKey(uid())"
@type="builder.setFieldType(uid(), $event)"
@required="builder.patchField(uid(), { required: $event })"
@unique="builder.patchField(uid(), { unique: $event })"
@limit="(which, value) => builder.setLimit(uid(), which, value)"
@default="builder.setDefault(uid(), $event)"
@default-on="builder.patchField(uid(), { defaultOn: $event })"
@add-choice="builder.addChoice(uid(), $event)"
@remove-choice="builder.removeChoice(uid(), $event)"
@rename-choice="(index, text) => builder.renameChoice(uid(), index, text)"
@reorder-choices="builder.reorderChoices(uid(), $event)"
:focus-token="focusToken"
@label="actions.setLabel(uid(), $event)"
@key="actions.setFieldKey(uid(), $event)"
@unlock="actions.unlockFieldKey(uid())"
@reset="actions.resetFieldKey(uid())"
@type="actions.setFieldType(uid(), $event)"
@required="actions.patchField(uid(), { required: $event })"
@unique="actions.patchField(uid(), { unique: $event })"
@limit="(which, value) => actions.setLimit(uid(), which, value)"
@default="actions.setDefault(uid(), $event)"
@default-on="actions.patchField(uid(), { defaultOn: $event })"
@add-choice="actions.addChoice(uid(), $event)"
@remove-choice="actions.removeChoice(uid(), $event)"
@rename-choice="(index, text) => actions.renameChoice(uid(), index, text)"
@reorder-choices="actions.reorderChoices(uid(), $event)"
/>
</template>
+6 -31
View File
@@ -2,11 +2,10 @@
import { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useRoute } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { useApi } from '@/composables/useApi'
import { useContentTypesPreload, useShellLogout } from '@/composables/useShellSession'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
@@ -68,27 +67,15 @@ const bodyUi = computed(() => {
})
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const contentTypes = useContentTypesStore()
const { loggingOut, logout } = useShellLogout()
const wantsBuiltin = computed(
() => session.canManageContent && route.meta.needsBuiltinTypes === true,
)
watch(
[() => session.status === 'ready' && session.session?.verified === true, wantsBuiltin],
([verified, builtin], [wasVerified]) => {
if (verified) {
contentTypes.load({ builtin })
} else if (wasVerified) {
contentTypes.reset()
}
},
{ immediate: true },
)
useContentTypesPreload(() => wantsBuiltin.value)
const publishingChildren = computed(() => [
{ label: t('Publishing'), type: 'label' as const },
@@ -237,7 +224,6 @@ const navUi = computed(() => ({
childLink: 'h-8 max-lg:h-11 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
childLinkIcon: 'size-3.5',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
const accountSheetOpen = ref(false)
const NAVBAR_TOGGLE_SELECTOR = '[data-slot="toggle"]'
@@ -272,17 +258,6 @@ const showBoot = computed(() => session.status === 'checking' && !props.renderWh
function onRetry() {
session.retry()
}
async function onLogout() {
loggingOut.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
loggingOut.value = false
}
session.clear()
await router.push('/login')
}
</script>
<template>
@@ -335,7 +310,7 @@ async function onLogout() {
:email="session.session?.email"
:collapsed="sidebarCollapsed"
:sheet-open="accountSheetOpen"
@logout="onLogout"
@logout="logout"
@open-sheet="openAccountSheet"
/>
</template>
@@ -438,7 +413,7 @@ async function onLogout() {
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
@logout="onLogout"
@logout="logout"
/>
</UDashboardGroup>
</template>
+21 -19
View File
@@ -2,9 +2,18 @@
import { t } from '@/i18n'
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import type { EntryChipStatus } from '@/composables/useEntryEditorPage'
defineProps<{ page: EntryEditorPage; bar?: boolean }>()
defineProps<{
chip: EntryChipStatus
savedClock: string
creating: boolean
saving: boolean
canSave: boolean
bar?: boolean
}>()
defineEmits<{ remove: []; cancel: [] }>()
</script>
<template>
@@ -13,20 +22,16 @@ defineProps<{ page: EntryEditorPage; bar?: boolean }>()
:class="bar ? 'w-full gap-2' : 'gap-3'"
:data-testid="bar ? 'entry-bar' : 'entry-actions'"
>
<BuilderStatusChip
v-if="!bar"
:status="page.chip.value"
:saved-at="page.savedClock.value"
/>
<BuilderStatusChip v-if="!bar" :status="chip" :saved-at="savedClock" />
<UButton
v-if="!bar && !page.creating.value"
v-if="!bar && !creating"
type="button"
color="error"
variant="outline"
icon="i-lucide-trash-2"
:disabled="page.saving.value"
:disabled="saving"
class="h-8 px-3"
@click="page.removal.open"
@click="$emit('remove')"
>
{{ t('Delete') }}
</UButton>
@@ -34,25 +39,22 @@ defineProps<{ page: EntryEditorPage; bar?: boolean }>()
type="button"
color="neutral"
variant="outline"
:disabled="page.saving.value"
:disabled="saving"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
:class="bar ? 'flex-1 justify-center' : ''"
@click="page.cancel"
@click="$emit('cancel')"
>
{{ t('Cancel') }}
</UButton>
<UButton
type="submit"
form="entry-form"
:disabled="!page.canSave.value"
:loading="page.saving.value"
:disabled="!canSave"
:loading="saving"
class="h-11 px-4 md:h-8"
:class="[
bar ? 'flex-1 justify-center' : '',
page.saving.value ? 'disabled:opacity-100' : '',
]"
:class="[bar ? 'flex-1 justify-center' : '', saving ? 'disabled:opacity-100' : '']"
>
{{ page.saving.value ? t('Saving…') : t('Save') }}
{{ saving ? t('Saving…') : t('Save') }}
</UButton>
</div>
</template>
+42 -4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
import ContentState from '@/components/ContentState.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
@@ -28,6 +29,14 @@ function loadErrorLine() {
)
}
const actionProps = computed(() => ({
chip: props.page.chip.value,
savedClock: props.page.savedClock.value,
creating: props.page.creating.value,
saving: props.page.saving.value,
canSave: props.page.canSave.value,
}))
function goHome() {
router.push(HOME_PATH)
}
@@ -65,7 +74,12 @@ function backToList() {
<p class="text-muted">{{ page.subtitle.value }}</p>
</template>
</div>
<EntryActions v-if="isDesktop && phase === 'ready'" :page="page" />
<EntryActions
v-if="isDesktop && phase === 'ready'"
v-bind="actionProps"
@remove="page.removal.open"
@cancel="page.cancel"
/>
</div>
<div
@@ -83,8 +97,32 @@ function backToList() {
/>
<EntryEditorSkeleton v-else-if="phase === 'loading'" />
<template v-else>
<EntryNotices :page="page" />
<EntryForm :page="page" />
<EntryNotices
:failure="page.failure.value"
:schema-changed="page.schemaChanged.value"
:schema-field="page.schemaField.value"
:issues="page.form.issues.value"
:fields="page.fields.value"
:type-name="page.typeName.value"
@retry="page.submit"
@reload="page.reload"
/>
<EntryForm
:fields="page.fields.value"
:values="page.form.values.value"
:issues="page.form.issues.value"
:first-issue="page.form.firstIssue.value"
:focus-token="page.focusToken.value"
:locked="page.locked.value"
:saving="page.saving.value"
:creating="page.creating.value"
:is-default="page.form.isDefault"
:entry-line="page.entryLine.value"
:created-line="page.createdLine.value"
:updated-line="page.updatedLine.value"
@update="page.change"
@submit="page.submit"
/>
</template>
</div>
@@ -92,7 +130,7 @@ function backToList() {
v-if="!isDesktop && phase === 'ready'"
class="sticky bottom-0 -mx-4 mt-auto flex border-t border-default bg-default px-4 py-3"
>
<EntryActions :page="page" bar />
<EntryActions v-bind="actionProps" bar @remove="page.removal.open" @cancel="page.cancel" />
</div>
<EntryDeleteDialog
+31 -17
View File
@@ -3,21 +3,35 @@ import { t } from '@/i18n'
import { computed, nextTick, watch } from 'vue'
import EntryField from '@/components/EntryField.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import type { BuiltinField } from '@/lib/contentTypes'
import type { EntryIssues, EntryValues } from '@/lib/entryForm'
import { groupFields } from '@/lib/entryLayout'
const FOCUSABLE =
'input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
const props = defineProps<{ page: EntryEditorPage }>()
const props = defineProps<{
fields: BuiltinField[]
values: EntryValues
issues: EntryIssues
firstIssue: string | null
focusToken: number
locked: boolean
saving: boolean
creating: boolean
isDefault: (field: BuiltinField) => boolean
entryLine: string
createdLine: string
updatedLine: string
}>()
const { form, fields, locked, focusToken } = props.page
const rows = computed(() => groupFields(fields.value))
const dimmed = computed(() => locked.value)
const emit = defineEmits<{ update: [key: string, value: unknown]; submit: [] }>()
const rows = computed(() => groupFields(props.fields))
async function focusFirstIssue() {
await nextTick()
const key = form.firstIssue.value
const key = props.firstIssue
if (!key) {
return
}
@@ -29,7 +43,7 @@ async function focusFirstIssue() {
;(wrapper ?? control)?.scrollIntoView?.({ block: 'center' })
}
watch(focusToken, focusFirstIssue)
watch(() => props.focusToken, focusFirstIssue)
</script>
<template>
@@ -38,9 +52,9 @@ watch(focusToken, focusFirstIssue)
novalidate
data-testid="entry-form"
class="flex max-w-190 flex-col gap-5 border border-default bg-default p-4 transition-opacity md:p-6"
:class="dimmed ? 'opacity-70' : ''"
:aria-busy="page.saving.value ? 'true' : undefined"
@submit.prevent="page.submit"
:class="locked ? 'opacity-70' : ''"
:aria-busy="saving ? 'true' : undefined"
@submit.prevent="emit('submit')"
>
<div
v-for="row in rows"
@@ -52,11 +66,11 @@ watch(focusToken, focusFirstIssue)
v-for="field in row"
:key="field.key"
:field="field"
:value="form.values.value[field.key]"
:issue="form.issues.value[field.key]"
:is-default="form.isDefault(field)"
:value="values[field.key]"
:issue="issues[field.key]"
:is-default="isDefault(field)"
:disabled="locked"
@update="page.change"
@update="(key, value) => emit('update', key, value)"
/>
</div>
@@ -65,11 +79,11 @@ watch(focusToken, focusFirstIssue)
class="grid grid-cols-1 gap-x-4 gap-y-1.5 border-t border-default pt-4 text-xs/4.5 md:grid-cols-[auto_1fr]"
>
<dt class="text-muted">{{ t('Entry') }}</dt>
<dd :class="page.creating.value ? '' : 'font-mono'">{{ page.entryLine.value }}</dd>
<dd :class="creating ? '' : 'font-mono'">{{ entryLine }}</dd>
<dt class="text-muted">{{ t('Created by') }}</dt>
<dd>{{ page.createdLine.value }}</dd>
<dd>{{ createdLine }}</dd>
<dt class="text-muted">{{ t('Updated by') }}</dt>
<dd>{{ page.updatedLine.value }}</dd>
<dd>{{ updatedLine }}</dd>
</dl>
</form>
</template>
+15 -6
View File
@@ -3,15 +3,24 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import RetryAlert from '@/components/RetryAlert.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import type { SaveFailure } from '@/composables/useEntryDraft'
import type { BuiltinField } from '@/lib/contentTypes'
import type { EntryIssues } from '@/lib/entryForm'
import { schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
const props = defineProps<{ page: EntryEditorPage }>()
const props = defineProps<{
failure: SaveFailure
schemaChanged: boolean
schemaField: string
issues: EntryIssues
fields: BuiltinField[]
typeName: string
}>()
const { failure, schemaChanged, schemaField, form, fields, typeName } = props.page
defineEmits<{ retry: []; reload: [] }>()
const uniqueField = computed(() =>
fields.value.find((field) => form.issues.value[field.key]?.rule === 'unique'),
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'),
)
const alertUi = {
root: 'items-start gap-2.5 px-3.5 py-3',
@@ -29,7 +38,7 @@ const alertUi = {
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
)
"
@retry="page.submit"
@retry="$emit('retry')"
/>
<UAlert
@@ -61,7 +70,7 @@ const alertUi = {
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="page.reload"
@click="$emit('reload')"
>
{{ t('Reload form') }}
</UButton>
+1 -1
View File
@@ -42,7 +42,7 @@ const height = computed(() => (props.touch ? 'h-11' : 'h-8'))
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: `${height} rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)`,
item: `${height} items-center rounded-none py-0 data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)`,
}"
@update:model-value="emit('update:size', $event as number)"
/>
+6 -2
View File
@@ -256,7 +256,9 @@ async function restoreRowFocus() {
<BuilderBadge v-if="selectedMarks?.changed" kind="changed" />
</div>
<BuilderInspector
:builder="builder"
:actions="builder"
:saving="builder.saving.value"
:focus-token="builder.labelFocusToken.value"
:field="builder.selected.value"
:errors="selectedErrors"
:compact="false"
@@ -313,7 +315,9 @@ async function restoreRowFocus() {
>
<BuilderInspector
v-if="builder.selected.value"
:builder="builder"
:actions="builder"
:saving="builder.saving.value"
:focus-token="builder.labelFocusToken.value"
:field="builder.selected.value"
:errors="selectedErrors"
compact
+40
View File
@@ -0,0 +1,40 @@
import { ref, type Ref } from 'vue'
import { useLocalizedToast } from '@/i18n/presentation'
interface ConfirmedDeleteOptions {
toastId: string
toastTitle: () => string
}
export function useConfirmedDelete<Stage extends string>(options: ConfirmedDeleteOptions) {
const toast = useLocalizedToast()
const stage = ref('closed') as Ref<Stage | 'closed'>
const pending = ref(false)
const done = ref(false)
function announce(description: () => string) {
toast.add(() => ({
id: options.toastId,
color: 'success',
icon: 'i-lucide-circle-check',
title: options.toastTitle(),
description: description(),
}))
}
async function leave(description: () => string, navigate: () => Promise<unknown>) {
done.value = true
stage.value = 'closed'
announce(description)
const arrived = await navigate().then(
(failure) => !failure,
() => false,
)
if (!arrived) {
done.value = false
}
}
return { stage, pending, done, leave }
}
+8 -25
View File
@@ -1,8 +1,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useConfirmedDelete } from '@/composables/useConfirmedDelete'
import { useEntries } from '@/composables/useEntries'
import type { EntryDraft } from '@/composables/useEntryDraft'
import { isGoneError } from '@/lib/entryServerError'
@@ -15,12 +14,12 @@ export type RemovalStage = 'closed' | 'confirm' | 'failed'
export function useEntryRemoval(editor: EntryDraft, typeName: () => string, title: () => string) {
const router = useRouter()
const toast = useLocalizedToast()
const { deleteEntry } = useEntries()
const stage = ref<RemovalStage>('closed')
const pending = ref(false)
const done = ref(false)
const { stage, pending, done, leave } = useConfirmedDelete<RemovalStage>({
toastId: TOAST_ID,
toastTitle: () => t('Entry deleted'),
})
function open() {
if (!pending.value && !editor.saving.value && editor.entryId.value !== null) {
@@ -34,16 +33,6 @@ export function useEntryRemoval(editor: EntryDraft, typeName: () => string, titl
}
}
function announce(deletedTitle: string) {
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Entry deleted'),
description: t('"{0}" was deleted from {1}.', [deletedTitle, typeName()]),
}))
}
function refuse(error: unknown) {
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
return
@@ -72,16 +61,10 @@ export function useEntryRemoval(editor: EntryDraft, typeName: () => string, titl
return
}
pending.value = false
done.value = true
stage.value = 'closed'
announce(deletedTitle)
const arrived = await router.replace(contentPath(editor.typeKey)).then(
(failure) => !failure,
() => false,
await leave(
() => t('"{0}" was deleted from {1}.', [deletedTitle, typeName()]),
() => router.replace(contentPath(editor.typeKey)),
)
if (!arrived) {
done.value = false
}
}
return { stage, pending, done, open, close, confirm }
+11 -31
View File
@@ -2,9 +2,9 @@ import { computed, ref } from 'vue'
import { useOnline } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useContentTypes, type ContentType } from '@/composables/useContentTypes'
import { useConfirmedDelete } from '@/composables/useConfirmedDelete'
import { useContentTypes } from '@/composables/useContentTypes'
import type { EditSource } from '@/composables/useSchemaBuilder'
import { contentLink } from '@/lib/contentNav'
import { isConfirmStage, type DeleteStage } from '@/lib/schemaDeleteStage'
@@ -17,13 +17,13 @@ const TOAST_ID = 'content-type-deleted'
export function useSchemaDelete(source: () => EditSource) {
const router = useRouter()
const store = useContentTypesStore()
const toast = useLocalizedToast()
const online = useOnline()
const stage = ref<DeleteStage>('closed')
const pending = ref(false)
const { stage, pending, done, leave } = useConfirmedDelete<DeleteStage>({
toastId: TOAST_ID,
toastTitle: () => t('Content type deleted'),
})
const forbidden = ref(false)
const done = ref(false)
const offline = computed(() => !online.value)
const type = computed(() => source().type)
const total = computed(() => source().total)
@@ -46,30 +46,6 @@ export function useSchemaDelete(source: () => EditSource) {
await router.push(contentLink(type.value.key)).catch(() => undefined)
}
function announceDeleted(name: string) {
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Content type deleted'),
description: t('{0} was deleted.', [name]),
}))
}
async function leave(deleted: ContentType) {
done.value = true
stage.value = 'closed'
announceDeleted(deleted.name)
const arrived = await router.push(SCHEMA_PATH).then(
(failure) => !failure,
() => false,
)
if (!arrived) {
done.value = false
}
store.drop(deleted.key)
}
function refuse(error: unknown, key: string) {
const status = error instanceof ApiError ? error.status : 0
const body = error instanceof ApiError ? error.body : undefined
@@ -104,7 +80,11 @@ export function useSchemaDelete(source: () => EditSource) {
return
}
pending.value = false
await leave(deleted)
await leave(
() => t('{0} was deleted.', [deleted.name]),
() => router.push(SCHEMA_PATH),
)
store.drop(deleted.key)
}
return {
+44
View File
@@ -0,0 +1,44 @@
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
const LOGIN_PATH = '/login'
export function useContentTypesPreload(wantsBuiltin: () => boolean) {
const session = useSessionStore()
const contentTypes = useContentTypesStore()
watch(
[() => session.status === 'ready' && session.session?.verified === true, wantsBuiltin],
([verified, builtin], [wasVerified]) => {
if (verified) {
contentTypes.load({ builtin })
} else if (wasVerified) {
contentTypes.reset()
}
},
{ immediate: true },
)
}
export function useShellLogout() {
const router = useRouter()
const session = useSessionStore()
const api = useApi()
const loggingOut = ref(false)
async function logout() {
loggingOut.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
loggingOut.value = false
}
session.clear()
await router.push(LOGIN_PATH)
}
return { loggingOut, logout }
}