diff options
author | vnugent <public@vaughnnugent.com> | 2024-04-09 17:35:13 -0400 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2024-04-09 17:35:13 -0400 |
commit | 56e0a38b2ca246e8beeaef3c6c4b9c0ce7d0f09b (patch) | |
tree | 5ba2556e42510cbbdf9c287f67041c1b1eb46206 /front-end/src/components/Boomarks | |
parent | 0945210c0492dd8a8de99ccd8e5e66cf05e3a1c1 (diff) |
chore(app): Update deps, login spinner, curl msg, view prep
Diffstat (limited to 'front-end/src/components/Boomarks')
-rw-r--r-- | front-end/src/components/Boomarks/AddOrUpdateForm.vue | 53 | ||||
-rw-r--r-- | front-end/src/components/Boomarks/BookmarkList.vue | 157 |
2 files changed, 192 insertions, 18 deletions
diff --git a/front-end/src/components/Boomarks/AddOrUpdateForm.vue b/front-end/src/components/Boomarks/AddOrUpdateForm.vue index 0370e0c..d6ea4bc 100644 --- a/front-end/src/components/Boomarks/AddOrUpdateForm.vue +++ b/front-end/src/components/Boomarks/AddOrUpdateForm.vue @@ -1,8 +1,10 @@ <script setup lang="ts"> -import { computed, toRefs } from 'vue'; -import { isEmpty, join, split } from 'lodash-es'; +import { computed, shallowRef, toRefs } from 'vue'; +import { set, watchDebounced } from '@vueuse/core' +import { isEmpty, join, noop, split } from 'lodash-es'; import { useStore } from '../../store'; -import { useWait } from '@vnuge/vnlib.browser'; +import { WebMessage, useWait } from '@vnuge/vnlib.browser'; +import { AxiosError } from 'axios'; const emit = defineEmits(['submit']) const props = defineProps<{ @@ -20,6 +22,8 @@ const tags = computed({ const { websiteLookup:lookup } = useStore() const { setWaiting, waiting } = useWait() +const errMessage = shallowRef(); + const execLookup = async () => { //url must be valid before searching if(v$.value.Url.$invalid) return @@ -45,9 +49,10 @@ const execLookup = async () => { v$.value.Tags.$dirty = true; } } - catch(e){ - //Mostly ignore errors + catch(e){ console.error(e) + const res = (e as AxiosError).response?.data; + set(errMessage, (res as WebMessage)?.result); } finally{ setWaiting(false) @@ -56,6 +61,9 @@ const execLookup = async () => { const showSearchButton = computed(() => lookup.isSupported && !isEmpty(v$.value.Url.$model)) +//Clear error message after 5 seconds +watchDebounced(errMessage, v => v ? setTimeout(() => set(errMessage, ''), 5000) : noop()) + </script> <template> <form id="bm-add-or-update-form" class="grid grid-cols-1 gap-4 p-4" @submit.prevent="emit('submit')"> @@ -67,22 +75,31 @@ const showSearchButton = computed(() => lookup.isSupported && !isEmpty(v$.value. <input type="text" id="url" class="input" placeholder="https://www.example.com" v-model="v$.Url.$model" :class="{'dirty': v$.Url.$dirty, 'error': v$.Url.$invalid}" required> - <div class=""> - <button - type="button" - :disabled="!showSearchButton || waiting" - @click.self.prevent="execLookup" - id="search-btn" - class="btn blue search-btn" - > - <svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" - viewBox="0 0 20 20"> - <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" - d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" /> - </svg> + <div class="my-auto"> + <button type="button" :disabled="!showSearchButton || waiting" @click.prevent="execLookup" + id="search-btn" class="btn blue search-btn"> + <span v-if="waiting" class="mx-auto"> + <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 animate-spin" viewBox="0 0 15 15"> + <path fill="currentColor" fill-rule="evenodd" + d="M8 .5V5H7V.5h1ZM5.146 5.854l-3-3l.708-.708l3 3l-.708.708Zm4-.708l3-3l.708.708l-3 3l-.708-.708Zm.855 1.849L14.5 7l-.002 1l-4.5-.006l.002-1Zm-9.501 0H5v1H.5v-1Zm5.354 2.859l-3 3l-.708-.708l3-3l.708.708Zm6.292 3l-3-3l.708-.708l3 3l-.708.708ZM8 10v4.5H7V10h1Z" + clip-rule="evenodd" /> + </svg> + </span> + <span v-else> + <svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" + viewBox="0 0 20 20"> + <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" /> + </svg> + </span> </button> </div> </div> + <div v-if="errMessage" class="pl-2"> + <p class="text-xs italic text-red-800 dark:text-red-500"> + {{ errMessage }} + </p> + </div> </fieldset> <fieldset> <label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Title</label> diff --git a/front-end/src/components/Boomarks/BookmarkList.vue b/front-end/src/components/Boomarks/BookmarkList.vue new file mode 100644 index 0000000..ac0d49b --- /dev/null +++ b/front-end/src/components/Boomarks/BookmarkList.vue @@ -0,0 +1,157 @@ +<script setup lang="ts"> +import { type Bookmark } from '../../store/bookmarks'; + +import { computed, ref } from 'vue'; +import { useStore } from '../../store'; +import { formatTimeAgo, useTimestamp, useClipboard } from '@vueuse/core'; +import { apiCall, useConfirm } from '@vnuge/vnlib.browser'; +import { join, truncate } from 'lodash-es'; + +const emit = defineEmits(['toggleTag', 'edit']); + +const store = useStore(); +const bookmarks = computed(() => store.bookmarks.list); +const readable = ref(true); //Future allow users to switch between clean and readable layout + +//Refresh on page load +store.bookmarks.refresh(); + +const { copy } = useClipboard() +const { reveal } = useConfirm(); +const now = useTimestamp({ interval: 1000 }); + +const bmDelete = async (bookmark: Bookmark) => { + const { isCanceled } = await reveal({ + title: 'Delete bookmark', + text: `Are you sure you want to delete ${bookmark.Name} ?`, + }) + + if (isCanceled) return; + + apiCall(async ({ toaster }) => { + + await store.bookmarks.api.delete(bookmark); + + toaster.general.success({ + title: 'Bookmark deleted', + text: 'Bookmark has been deleted successfully' + }); + + store.bookmarks.refresh(); + }) +} + +const truncatText = (desc: string) => truncate(desc, { length: 100 }); + +</script> + +<template> + <div class="grid h-full grid-cols-1 gap-0"> + <div v-for="bm in bookmarks" :key="bm.Id" :id="join(['bm', bm.Id], '-')" class="w-full p-1"> + <div v-if="readable" class="leading-tight md:leading-normal"> + <div class=""> + <a class="bl-link" :href="bm.Url" target="_blank"> + {{ bm.Name }} + </a> + </div> + <div class="flex flex-row items-center"> + <span v-for="tag in bm.Tags"> + <span class="mr-1 text-sm text-teal-500 cursor-pointer dark:text-teal-300" + @click="emit('toggleTag', tag)"> + #{{ tag }} + </span> + </span> + <p class="ml-2 text-sm text-gray-500 truncate dark:text-gray-400 text-ellipsis"> + {{ bm.Description }} + </p> + </div> + <div class="flex items-center gap-1.5"> + <span class="text-xs text-gray-500 dark:text-gray-400"> + {{ formatTimeAgo(new Date(bm.Created), {}, now) }} + </span> + | + <span class="flex flex-row gap-1.5"> + <button class="text-xs text-gray-700 dark:text-gray-400" @click="copy(bm.Url)"> + Copy + </button> + <button class="text-xs text-gray-700 dark:text-gray-400" @click="emit('edit', bm)"> + Edit + </button> + <button class="text-xs text-gray-700 dark:text-gray-400" @click="bmDelete(bm)"> + Delete + </button> + </span> + </div> + </div> + <div v-else class="leading-tight clean-layout"> + <div class="flex flex-row"> + <div class="flex-1"> + <a class="text-sm font-bold bl-link" :href="bm.Url" target="_blank"> + {{ bm.Name }} + </a> + </div> + <div class=""> + <span class="inline-flex gap-1"> + <button class="text-xs text-gray-700 dark:text-gray-400" @click="copy(bm.Url)"> + <svg class="w-5 h-5 text-gray-800 dark:text-white" aria-hidden="true" + xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" + viewBox="0 0 24 24"> + <path stroke="currentColor" stroke-linejoin="round" stroke-width="2" + d="M9 8v3a1 1 0 0 1-1 1H5m11 4h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-7a1 1 0 0 0-1 1v1m4 3v10a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-7.13a1 1 0 0 1 .24-.65L7.7 8.35A1 1 0 0 1 8.46 8H13a1 1 0 0 1 1 1Z" /> + </svg> + </button> + <button class="text-xs text-gray-700 dark:text-gray-400" @click="emit('edit', bm)"> + <svg class="w-5 h-5 text-gray-800 dark:text-white" aria-hidden="true" + xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" + viewBox="0 0 24 24"> + <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + stroke-width="2" + d="m14.304 4.844 2.852 2.852M7 7H4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4.5m2.409-9.91a2.017 2.017 0 0 1 0 2.853l-6.844 6.844L8 14l.713-3.565 6.844-6.844a2.015 2.015 0 0 1 2.852 0Z" /> + </svg> + </button> + <button class="text-xs text-gray-700 dark:text-gray-400 " @click="bmDelete(bm)"> + <svg class="w-5 h-5 text-gray-800 duration-100 ease-in dark:text-white trash" + aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" + fill="none" viewBox="0 0 24 24"> + <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" + stroke-width="2" + d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z" /> + </svg> + </button> + </span> + </div> + </div> + <div class="flex flex-row items-start"> + <p class="text-sm text-gray-500 dark:text-gray-300 max-w-[26rem] flex-auto"> + {{ truncatText(bm.Description) }} + </p> + <div class="flex flex-col flex-wrap items-end ml-5 class gap-x-2 max-h-16"> + <span v-for="tag in bm.Tags"> + <span class="mr-1 text-xs text-gray-500 duration-75 ease-linear cursor-pointer dark:text-gray-500 hover:text-teal-500 hover:dark:text-teal-400" + @click="emit('toggleTag', tag)"> + {{ tag }} + </span> + </span> + </div> + </div> + </div> + </div> + </div> +</template> + +<style lang="scss" scoped> + .clean-layout { + @apply shadow-sm md:px-6 p-3 border rounded-md h-[7rem]; + @apply bg-white dark:bg-gray-800 dark:border-gray-700 max-w-[40rem]; + + button svg{ + &:hover{ + @apply text-gray-500 dark:text-gray-300 ease-linear duration-75; + + &.trash{ + @apply hover:text-red-500; + } + } + } + } +</style>
\ No newline at end of file |