From 89051b3f1f9d2ffbafe93a0a15ef1413eb7af14a Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 21 Oct 2026 00:52:14 +0200 Subject: [PATCH] refac: duplicates, crap, dry etc. on last features commits --- web/src/components/BuilderInspector.vue | 52 +++++++++++++++-------- web/src/components/DashboardShell.vue | 37 +++------------- web/src/components/EntryActions.vue | 40 ++++++++--------- web/src/components/EntryEditor.vue | 46 ++++++++++++++++++-- web/src/components/EntryForm.vue | 48 +++++++++++++-------- web/src/components/EntryNotices.vue | 21 ++++++--- web/src/components/PageSizeSelect.vue | 2 +- web/src/components/SchemaBuilderPage.vue | 8 +++- web/src/composables/useConfirmedDelete.ts | 40 +++++++++++++++++ web/src/composables/useEntryRemoval.ts | 33 ++++---------- web/src/composables/useSchemaDelete.ts | 42 +++++------------- web/src/composables/useShellSession.ts | 44 +++++++++++++++++++ 12 files changed, 260 insertions(+), 153 deletions(-) create mode 100644 web/src/composables/useConfirmedDelete.ts create mode 100644 web/src/composables/useShellSession.ts diff --git a/web/src/components/BuilderInspector.vue b/web/src/components/BuilderInspector.vue index 8bbb4b5..f0f581a 100644 --- a/web/src/components/BuilderInspector.vue +++ b/web/src/components/BuilderInspector.vue @@ -6,8 +6,26 @@ import type { FieldErrors } from '@/lib/schemaBuilderValidation' type Builder = ReturnType +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 diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 2a4c991..61f3ead 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -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') -} @@ -438,7 +413,7 @@ async function onLogout() { :username="session.session?.username ?? ''" :display-name="session.session?.displayName" :email="session.session?.email" - @logout="onLogout" + @logout="logout" /> diff --git a/web/src/components/EntryActions.vue b/web/src/components/EntryActions.vue index ea91079..1e4b255 100644 --- a/web/src/components/EntryActions.vue +++ b/web/src/components/EntryActions.vue @@ -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: [] }>() diff --git a/web/src/components/EntryEditor.vue b/web/src/components/EntryEditor.vue index 6c9fa40..736e29a 100644 --- a/web/src/components/EntryEditor.vue +++ b/web/src/components/EntryEditor.vue @@ -1,6 +1,7 @@ diff --git a/web/src/components/EntryNotices.vue b/web/src/components/EntryNotices.vue index 307e69e..0cafba7 100644 --- a/web/src/components/EntryNotices.vue +++ b/web/src/components/EntryNotices.vue @@ -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')" /> {{ t('Reload form') }} diff --git a/web/src/components/PageSizeSelect.vue b/web/src/components/PageSizeSelect.vue index d596474..7340086 100644 --- a/web/src/components/PageSizeSelect.vue +++ b/web/src/components/PageSizeSelect.vue @@ -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)" /> diff --git a/web/src/components/SchemaBuilderPage.vue b/web/src/components/SchemaBuilderPage.vue index e3cc411..5da40a8 100644 --- a/web/src/components/SchemaBuilderPage.vue +++ b/web/src/components/SchemaBuilderPage.vue @@ -256,7 +256,9 @@ async function restoreRowFocus() { string +} + +export function useConfirmedDelete(options: ConfirmedDeleteOptions) { + const toast = useLocalizedToast() + + const stage = ref('closed') as Ref + 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) { + 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 } +} diff --git a/web/src/composables/useEntryRemoval.ts b/web/src/composables/useEntryRemoval.ts index b6af8eb..5c0fe76 100644 --- a/web/src/composables/useEntryRemoval.ts +++ b/web/src/composables/useEntryRemoval.ts @@ -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('closed') - const pending = ref(false) - const done = ref(false) + const { stage, pending, done, leave } = useConfirmedDelete({ + 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 } diff --git a/web/src/composables/useSchemaDelete.ts b/web/src/composables/useSchemaDelete.ts index 5d1ce2d..a5da348 100644 --- a/web/src/composables/useSchemaDelete.ts +++ b/web/src/composables/useSchemaDelete.ts @@ -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('closed') - const pending = ref(false) + const { stage, pending, done, leave } = useConfirmedDelete({ + 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 { diff --git a/web/src/composables/useShellSession.ts b/web/src/composables/useShellSession.ts new file mode 100644 index 0000000..b4fa494 --- /dev/null +++ b/web/src/composables/useShellSession.ts @@ -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 } +}