Jason Gorst 1 mese fa
parent
commit
67925eac72

+ 39 - 0
app/components/ComboBox.vue

@@ -0,0 +1,39 @@
+<template>
+  <PatchedAutoComplete
+    v-model="model"
+    :inputId="inputId"
+    :suggestions="currentSuggestions"
+    :minLength="0"
+    :showEmptyMessage="false"
+    :completeOnFocus="true"
+    fluid
+    @complete="(event) => onComplete(event)"
+  />
+</template>
+
+<script setup>
+const model = defineModel()
+
+const props = defineProps({
+  inputId: {
+    type: String,
+    required: false,
+    default: () => useId()
+  },
+
+  suggestions: {
+    type: Array,
+    required: true
+  }
+})
+
+const currentSuggestions = ref(_cloneDeep(props.suggestions))
+
+function onComplete({ query }) {
+  currentSuggestions.value = _filter(props.suggestions, (value) =>
+    _startsWith(_lowerCase(value), _lowerCase(query))
+  )
+}
+</script>
+
+<style scoped></style>

+ 402 - 0
app/components/EditorCard.vue

@@ -0,0 +1,402 @@
+<!--suppress VueUnrecognizedSlot -->
+<template>
+  <Card class="mx-auto max-w-prose">
+    <template #content>
+      <div class="flex flex-col gap-2">
+        <Message
+          v-if="isPresent(formErrors)"
+          severity="error"
+          pt:root="border border-transparent"
+        >
+          {{ _join(formErrors, " ") }}
+        </Message>
+
+        <div
+          v-for="({ type }, field) in props.fields"
+          :key="field"
+          class="group flex flex-col"
+        >
+          <Swap
+            :class="props.action !== 'view' && 'cursor-pointer'"
+            :disabled="props.action === 'view'"
+            @active="focusFormControl(field, type)"
+            @inactive="validate(field)"
+          >
+            <template #inactive>
+              <div class="ml-1 block text-sm text-primary dark:text-primary">
+                {{ _startCase(field) }}
+              </div>
+
+              <div
+                v-if="type === 'richText'"
+                class="trix-content border border-transparent px-3 py-2"
+                :class="props.action !== 'view' && 'group-hover:bg-primary/15'"
+                :tabindex="0"
+                v-html="model?.[field] || '&nbsp;'"
+              />
+
+              <div
+                v-else
+                class="border border-transparent px-3 py-2"
+                :class="props.action !== 'view' && 'group-hover:bg-primary/15'"
+                :tabindex="0"
+              >
+                {{ model?.[field] || "&nbsp;" }}
+              </div>
+            </template>
+
+            <template #active="{ deactivate }">
+              <div class="ml-1 block text-sm text-primary dark:text-primary">
+                {{ _startCase(field) }}
+              </div>
+
+              <TrixEditor
+                v-if="type === 'richText'"
+                v-model="model[field]"
+                :id="field"
+                :tabindex="0"
+                @blur="waitForAnimation(deactivate)"
+                :ref="(trixEditor) => (trixEditors[field] = trixEditor)"
+              />
+
+              <Select
+                v-else-if="type === 'select'"
+                v-model="model[field]"
+                :tabindex="0"
+                :labelId="field"
+                fluid
+                :options="options?.[field]"
+                @blur="waitForAnimation(deactivate)"
+                :ref="(select) => (selects[field] = select)"
+              />
+
+              <ComboBox
+                v-else-if="type === 'autocomplete'"
+                v-model="model[field]"
+                :tabindex="0"
+                :inputId="field"
+                :suggestions="suggestions?.[field]"
+                @blur="waitForAnimation(deactivate)"
+              />
+
+              <InputText
+                v-else
+                v-model="model[field]"
+                :id="field"
+                :tabindex="0"
+                fluid
+                @blur="waitForAnimation(deactivate)"
+              />
+            </template>
+          </Swap>
+
+          <Message
+            v-if="fieldErrors?.[field]"
+            severity="error"
+            size="small"
+            variant="simple"
+            class="ml-1"
+          >
+            {{ _join(fieldErrors?.[field], " ") }}
+          </Message>
+        </div>
+      </div>
+    </template>
+
+    <template #footer>
+      <div class="mt-4 flex flex-row justify-end gap-3">
+        <template v-if="isEdited">
+          <Button
+            severity="warn"
+            @click="confirmReset"
+          >
+            Reset
+          </Button>
+
+          <Button
+            :disabled="!valid"
+            @click="save"
+          >
+            Save
+          </Button>
+        </template>
+
+        <template v-else>
+          <Button
+            v-if="props.action === 'update'"
+            severity="danger"
+            @click="confirmDelete"
+          >
+            Delete
+          </Button>
+
+          <Button>
+            <!--suppress JSValidateTypes -->
+            <NuxtLink :to="props.redirectBack">Back</NuxtLink>
+          </Button>
+        </template>
+      </div>
+    </template>
+  </Card>
+</template>
+
+<script setup>
+const selects = ref({})
+const trixEditors = ref({})
+
+const props = defineProps({
+  name: {
+    type: String,
+    required: true
+  },
+
+  modelId: {
+    type: [String, Number],
+    required: false,
+    validator: (value, props) => props.action === "create" || isPresent(value)
+  },
+
+  action: {
+    type: String,
+    required: false,
+    default: "view",
+    validator: (value) => _includes(["create", "update", "view"], value)
+  },
+
+  fields: {
+    type: Object,
+    required: true
+  },
+
+  initialValue: {
+    type: Object,
+    required: true
+  },
+
+  options: {
+    type: Object,
+    default: {},
+    required: false
+  },
+
+  isSaved: {
+    type: Boolean,
+    required: true
+  },
+
+  redirectBack: {
+    type: [String, Object],
+    required: true
+  },
+
+  schema: {
+    type: Object,
+    required: true
+  }
+})
+
+const emit = defineEmits(["delete", "create", "update"])
+const confirm = useConfirm()
+const toast = useToast()
+
+const { original, model, editedFields, isEdited, revert } = useRevertible(
+  () => props.initialValue
+)
+
+const suggestions = ref(toValue(props.options))
+const valid = ref(true)
+const formErrors = ref([])
+const fieldErrors = ref({})
+
+onBeforeRouteLeave(async () => {
+  if (isEdited.value && !props.isSaved && !(await confirmLeave())) {
+    return false
+  }
+})
+
+async function focusFormControl(field, type) {
+  await nextTick()
+  const control = document.getElementById(field)
+  control.focus()
+
+  switch (type) {
+    case "select": {
+      // noinspection JSUnresolvedReference
+      selects.value[field].show()
+      break
+    }
+
+    case "richText": {
+      await nextTick()
+      const length = control.editor.getDocument().getLength()
+      control.editor.setSelectedRange(length - 1)
+      break
+    }
+
+    default: {
+      const length = _isNull(model.value[field]) ? 0 : model.value[field].length
+
+      control.setSelectionRange(length, length)
+    }
+  }
+}
+
+async function waitForAnimation(deactivate) {
+  await callAfterDelay(deactivate, 200)
+}
+
+async function reset() {
+  revert()
+  formErrors.value = []
+  fieldErrors.value = {}
+  await nextTick()
+
+  // noinspection JSUnresolvedReference
+  _forEach(_keys(_pickBy(props.fields, { type: "richText" })), (field) =>
+    trixEditors.value[field].reset()
+  )
+}
+
+function validate(field = null) {
+  const result = props.schema.safeParse(
+    props.action === "create" ? model.value : editedFields.value
+  )
+
+  if (result.success) {
+    valid.value = true
+    formErrors.value = []
+    fieldErrors.value = {}
+  } else {
+    valid.value = false
+    const flattened = z.flattenError(result.error)
+    formErrors.value = flattened.formErrors
+
+    if (field) {
+      fieldErrors.value[field] = flattened.fieldErrors[field]
+    } else {
+      fieldErrors.value = flattened.fieldErrors
+    }
+  }
+
+  return result
+}
+
+function save() {
+  const result = validate()
+
+  if (result.success) {
+    emit(props.action, result.data)
+  } else {
+    console.error(
+      "save() called with invalid data",
+      z.flattenError(result.error)
+    )
+  }
+}
+
+function confirmDelete() {
+  confirm.require({
+    header: "Really?",
+    icon: "ph:warning-bold",
+    message: `Do you want to delete this ${props.name}?`,
+    defaultFocus: "reject",
+
+    acceptProps: {
+      label: "Delete",
+      severity: "danger"
+    },
+
+    rejectProps: { label: "Cancel" },
+    accept: () => emit("delete"),
+
+    reject: () =>
+      toast.add({
+        severity: "info",
+        summary: "Cancelled.",
+        detail: "Delete cancelled.",
+        life: 3000
+      })
+  })
+}
+
+function confirmReset() {
+  // noinspection JSUnusedGlobalSymbols
+  const confirmOptions = {
+    header: "Reset?",
+    icon: "ph:warning-bold",
+    message: `Do you want to reset this ${props.name}?`,
+    defaultFocus: "reject",
+
+    acceptProps: {
+      label: "Reset",
+      severity: "warn"
+    },
+
+    rejectProps: { label: "Cancel" },
+    accept: async () => await reset(),
+
+    reject: () =>
+      toast.add({
+        severity: "info",
+        summary: "Cancelled.",
+        detail: "Reset cancelled.",
+        life: 3000
+      })
+  }
+
+  if (props.action === "create") {
+    _assign(confirmOptions, {
+      header: "Revert?",
+      message: "Do you want to revert your edits?",
+
+      acceptProps: {
+        label: "Revert",
+        severity: "warn"
+      },
+
+      reject: () =>
+        toast.add({
+          severity: "info",
+          summary: "Cancelled.",
+          detail: "Revert cancelled.",
+          life: 3000
+        })
+    })
+  }
+
+  confirm.require(confirmOptions)
+}
+
+async function confirmLeave() {
+  const modal = new Promise((resolve) => {
+    const confirmOptions = {
+      header: "Leave?",
+      icon: "ph:warning-bold",
+      message: `Do you want to abandon this ${props.name}?`,
+      defaultFocus: "reject",
+
+      acceptProps: {
+        label: "Leave",
+        severity: "danger"
+      },
+
+      rejectProps: { label: "Cancel" },
+
+      accept: () => resolve(true),
+      reject: () => resolve(false)
+    }
+
+    if (props.action === "create") {
+      _assign(confirmOptions, {
+        message: "Do you want to abandon your edits?"
+      })
+    }
+
+    confirm.require(confirmOptions)
+  })
+
+  return await modal
+}
+</script>
+
+<style scoped></style>

+ 119 - 0
app/components/ResetPasswordRequestDialog.vue

@@ -0,0 +1,119 @@
+<template>
+  <Form
+    class="flex flex-col gap-2 p-5"
+    v-slot="$form"
+    :initialValues="initialValues"
+    :resolver="resolver"
+    :validateOnValueUpdate="false"
+    :validateOnBlur="true"
+    @submit="onFormSubmit"
+  >
+    <div class="flex flex-col">
+      <label
+        for="email"
+        class="ml-1 text-sm text-primary dark:text-primary"
+      >
+        Email Address
+      </label>
+
+      <InputText
+        name="email"
+        fluid
+        id="email"
+        type="email"
+        placeholder="Email address"
+        autocomplete="email"
+      />
+
+      <Message
+        v-if="$form.email?.invalid"
+        severity="error"
+        size="small"
+        variant="simple"
+      >
+        {{ $form.email?.error?.message }}
+      </Message>
+    </div>
+
+    <Button
+      label="Request Password Reset"
+      type="submit"
+      fluid
+      :disabled="!$form.valid"
+      :loading="isLoading"
+      class="mt-5"
+    >
+      <template #loadingicon>
+        <Icon
+          class="animate-spin"
+          name="ph:circle-notch-bold"
+        />
+      </template>
+    </Button>
+  </Form>
+</template>
+
+<script setup>
+import { zodResolver } from "@primeuix/forms/resolvers/zod"
+
+const dialogRef = inject("dialogRef")
+const toast = useToast()
+const { authClient } = useAuthClient()
+
+const resolver = ref(
+  zodResolver(
+    z.object({
+      email: z.email("Not an email address.")
+    })
+  )
+)
+
+const initialValues = ref({ email: "" })
+const isLoading = ref(false)
+
+async function onFormSubmit({ valid, values }) {
+  if (valid) {
+    await resetPasswordRequest(values.email)
+    closeDialog()
+  }
+}
+
+async function resetPasswordRequest(email) {
+  if (isLoading.value) {
+    return
+  }
+
+  isLoading.value = true
+
+  // noinspection JSUnresolvedReference
+  const { error } = await authClient.requestPasswordReset({
+    email: email,
+    redirectTo: { name: "reset-password" }
+  })
+
+  isLoading.value = false
+
+  if (error) {
+    console.error(error)
+
+    toast.add({
+      severity: "error",
+      summary: "Request Password Reset Error.",
+      detail: error.data?.message || error.message
+    })
+  } else {
+    toast.add({
+      severity: "success",
+      summary: "Password Reset Requested.",
+      detail: "You will receive an email with a link to reset your password.",
+      life: 3000
+    })
+  }
+}
+
+function closeDialog() {
+  dialogRef.value.close()
+}
+</script>
+
+<style scoped></style>

+ 71 - 0
app/components/Swap.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="contents">
+    <div
+      data-name="inactive"
+      :class="isActive && 'hidden'"
+      @click="activate"
+      @keydown.enter.prevent="activate"
+      @keydown.space.prevent="activate"
+    >
+      <slot name="default" />
+
+      <slot
+        name="inactive"
+        :activate="activate"
+      />
+    </div>
+
+    <div
+      data-name="active"
+      :class="!isActive && 'hidden'"
+      @blur="deactivate"
+    >
+      <slot name="default" />
+
+      <slot
+        name="active"
+        :deactivate="deactivate"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineExpose({ activate, deactivate })
+
+const props = defineProps({
+  active: {
+    type: Boolean,
+    default: false
+  },
+
+  disabled: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits([
+  "active",
+  "inactive"
+])
+
+const isActive = ref(!props.disabled && props.active)
+
+function activate() {
+  if (!props.disabled && !isActive.value) {
+    // noinspection JSValidateTypes
+    isActive.value = true
+    emit("active")
+  }
+}
+
+function deactivate() {
+  if (isActive.value) {
+    isActive.value = false
+    emit("inactive")
+  }
+}
+</script>
+
+<style scoped></style>

+ 40 - 0
app/components/ToastContainer.vue

@@ -0,0 +1,40 @@
+<template>
+  <Toast class="top-14!">
+    <template #messageicon="{ class: messageIconClass }">
+      <div :class="messageIconClass">
+        <template v-for="{ className, iconName } in messageIcons">
+          <Icon
+            :class="className"
+            :name="iconName"
+            size="1.125rem"
+          />
+        </template>
+      </div>
+    </template>
+
+    <template #closeicon="{ class: closeIconClass }">
+      <span
+        :class="closeIconClass"
+        class="self-start"
+      >
+        <Icon name="ph:x-bold" />
+      </span>
+    </template>
+  </Toast>
+</template>
+
+<script setup>
+const messageIcons = [
+  { className: "not-group-p-success:hidden", iconName: "ph:check-circle-fill" },
+  { className: "not-group-p-info:hidden", iconName: "ph:info-fill" },
+  { className: "not-group-p-warn:hidden", iconName: "ph:warning-circle-fill" },
+  { className: "not-group-p-danger:hidden", iconName: "ph:warning-fill" },
+  {
+    className: "not-group-p-secondary:hidden",
+    iconName: "ph:check-circle-fill"
+  },
+  { className: "not-group-p-contrast:hidden", iconName: "ph:check-circle-fill" }
+]
+</script>
+
+<style scoped></style>