/ Gists / cdruc vue

Gists - cdruc vue

On gists

24 useForm

cdruc vue

useForm.js #

import {reactive} from "vue";

export default function useForm(fields) {
  return reactive({
    fields,
    processing: false,
    error: null,
    async submit(submitter) {
      if (this.processing) return;

      this.error = null;
      this.processing = true;

      try {
        await submitter(this.fields);
      } catch (err) {
        this.error = err;
      }

      this.processing = false;
    },
  });
}

On gists

22 UseLocalStorage

cdruc vue

useLocalStorage.js #

// https://tallpad.com/series/vuejs-misc/lessons/vue-state-and-localstorage-perfect-sync-made-simple
// onMounted => SSR


import {onMounted, ref, watch} from "vue";

export default function (initialValue, key) {
  const val = ref(initialValue);

  onMounted(() => {
    const storageVal = window.localStorage.getItem(key);

    if (storageVal) {
      val.value = JSON.parse(storageVal);
    }

    watch(
      val,
      val => {
        window.localStorage.setItem(key, JSON.stringify(val));
      },
      {deep: true}
    );
  });

  return val;
}

On gists

7 Google maps autocomplete

cdruc vue

index.html #

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Tallpad</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://maps.googleapis.com/maps/api/js?key=YOUR_GOOGLE_API_KEY&libraries=places"></script>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

On gists

16 TOC

cdruc vue

Toc.vue #

// https://tallpad.com/series/vuejs-misc/lessons/table-of-contents-component-using-vuejs

<script setup>
import {onMounted, ref, computed} from "vue";
import TocList from "./TocList.vue";
import {slugifyWithCounter} from "@sindresorhus/slugify";

const props = defineProps({
  contentSelector: {
    type: String,
    required: true,
  },
});

const slugify = slugifyWithCounter();

const headings = ref([]);

onMounted(() => {
  window.document
    .querySelector(props.contentSelector)
    .querySelectorAll("h1, h2, h3, h4, h5, h6")
    .forEach(el => {
      let id = slugify(el.innerText);
      el.setAttribute("id", id);

      headings.value.push({
        id: id,
        level: parseInt(el.tagName.charAt(1), 10),
        content: el.innerText,
        subheadings: [],
      });
    });
});

const groupedHeadings = computed(() => {
  let items = [...headings.value];

  for (let i = items.length - 1; i >= 0; i--) {
    let currentItem = items[i];

    let parentItem = items.findLast((item, index) => {
      return item.level < currentItem.level && index < i;
    });

    if (parentItem) {
      parentItem.subheadings.unshift(currentItem);
      items.splice(i, 1);
    }
  }

  return items;
});
</script>

<template>
  <div class="bg-slate-50 -mx-6 p-6" v-if="groupedHeadings.length">
    <h3
      class="border-b-2 border-slate-300 inline-block uppercase font-bold tracking-wide text-slate-800 mb-5"
    >
      Contents:
    </h3>

    <TocList :items="groupedHeadings" />
  </div>
</template>

On gists

15 Alert , Tailwind + CVA

cdruc vue

Alert.vue #

// https://tallpad.com/series/vuejs-misc/lessons/build-an-alert-component-using-vue-tailwindcss-and-cva


<script setup>
import {InformationCircleIcon, XMarkIcon, CheckCircleIcon, ExclamationTriangleIcon, XCircleIcon} from "@heroicons/vue/20/solid/index.js";
import {computed} from "vue";
import {cva} from "class-variance-authority";

const props = defineProps({
  intent: {
    type: String,
    validator(value) {
      return ["info", "success", "danger", "warning"].includes(value);
    },
    default: "info",
  },
  title: String,
  show: {
    type: Boolean,
    default: true,
  },
  onDismiss: Function,
});

const containerClass = computed(() => {
  return cva("flex p-4 rounded-md space-x-3", {
    variants: {
      intent: {
        info: "bg-blue-100",
        success: "bg-green-100",
        warning: "bg-orange-100",
        danger: "bg-red-100",
      },
    },
  })({
    intent: props.intent,
  });
});

const iconClass = computed(() => {
  return cva("w-6 h-6", {
    variants: {
      intent: {
        info: "text-blue-700",
        success: "text-green-600",
        warning: "text-orange-400",
        danger: "text-red-500",
      },
    },
  })({
    intent: props.intent,
  });
});

const titleClass = computed(() => {
  return cva("font-medium", {
    variants: {
      intent: {
        info: "text-blue-900",
        success: "text-green-900",
        warning: "text-orange-900",
        danger: "text-red-900"
      },
    },
  })({
    intent: props.intent,
  });
});

const contentClass = computed(() => {
  return cva("text-sm", {
    variants: {
      intent: {
        info: "text-blue-800",
        success: "text-green-800",
        warning: "text-orange-800",
        danger: "text-red-800",
      },
    },
  })({
    intent: props.intent,
  });
});

const closeButtonClass = computed(() => {
  return cva("p-0.5 rounded-md -m-1", {
    variants: {
      intent: {
        info: "text-blue-900/70 hover:text-blue-900 hover:bg-blue-200",
        success: "text-green-900/70 hover:text-green-900 hover:bg-green-200",
        warning: "text-orange-900/70 hover:text-orange-900 hover:bg-orange-200",
        danger: "text-red-900/70 hover:text-red-900 hover:bg-red-200",
      },
    },
  })({
    intent: props.intent,
  });
});

const iconComponent = computed(() => {
  const icons = {
    success: CheckCircleIcon,
    warning: ExclamationTriangleIcon,
    danger: XCircleIcon,
    info: InformationCircleIcon,
  };

  return icons[props.intent];
});

function dismiss() {
  if (props.onDismiss) {
    props.onDismiss();
  }
}
</script>

<template>
  <div v-if="props.show" :class="containerClass">
    <div class="shrink-0">
      <component :is="iconComponent" :class="iconClass" />
    </div>
    <div class="flex-1 space-y-2">
      <h2 :class="titleClass">
        {{ props.title }}
      </h2>
      <div :class="contentClass"><slot /></div>
    </div>
    <div class="shrink-0" v-if="props.onDismiss">
      <button @click="dismiss()" :class="closeButtonClass">
        <XMarkIcon class="w-6 h-6" />
      </button>
    </div>
  </div>
</template>

On gists

12 multiple watchers

cdruc vue

Page.vue #

<script setup>
import {reactive, watch} from "vue";
import BaseLabel from "./BaseLabel.vue";
import BaseInput from "./BaseInput.vue";
import BaseTextarea from "./BaseTextarea.vue";
import SecondaryButton from "./SecondaryButton.vue";
import BaseButton from "./BaseButton.vue";
import BaseCheckbox from "./BaseCheckbox.vue";
import BaseSwitch from "./BaseSwitch.vue";

const form = reactive({
  name: null,
  is_sellable: false,
  is_available_online: false,
  is_available_in_stores: false,
  price: null,
  description: null,
});

/**
 * Separate watchers
 */
watch(
  () => form.is_available_online,
  isAvailableOnline => {
    if (!isAvailableOnline && !form.is_available_in_stores) {
      form.is_sellable = false;
    }
  }
);

watch(
  () => form.is_available_in_stores,
  isAvailableInStores => {
    if (!isAvailableInStores && !form.is_available_online) {
      form.is_sellable = false;
    }
  }
);

/**
 * Watch multiple values in the same watch function
 */
watch(
  () => [form.is_available_online, form.is_available_in_stores],
  ([isAvailableOnline, isAvailableInStores]) => {
    if (!isAvailableOnline && !isAvailableInStores) {
      form.is_sellable = false;
    }
  }
);

/**
 * Combine watched values
 */
watch(
  () => form.is_available_online || form.is_available_in_stores,
  isSellable => (form.is_sellable = isSellable)
);
</script>

<template>
  <form class="bg-white p-6 rounded-xl shadow">
    <div class="space-y-6 sm:space-y-5">
      <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start">
        <BaseLabel required>Name</BaseLabel>
        <div class="mt-1 sm:mt-0 sm:col-span-2">
          <BaseInput
            v-model="form.name"
            placeholder="Enter product name"
            type="text"
          ></BaseInput>
        </div>
      </div>

      <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start">
        <BaseLabel>Can be sold?</BaseLabel>
        <div class="mt-1 sm:mt-0 sm:col-span-2">
          <BaseSwitch v-model="form.is_sellable"></BaseSwitch>
        </div>
      </div>

      <template v-if="form.is_sellable">
        <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start">
          <BaseLabel>Available in online stores</BaseLabel>
          <div class="mt-1 sm:mt-0 sm:col-span-2">
            <BaseCheckbox v-model:checked="form.is_available_online" />
          </div>
        </div>

        <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start">
          <BaseLabel>Available in physical stores</BaseLabel>
          <div class="mt-1 sm:mt-0 sm:col-span-2">
            <BaseCheckbox v-model:checked="form.is_available_in_stores" />
          </div>
        </div>

        <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start">
          <BaseLabel>Price</BaseLabel>
          <div class="mt-1 sm:mt-0 sm:col-span-2">
            <BaseInput
              v-model="form.price"
              placeholder="Enter price"
              type="number"
            ></BaseInput>
          </div>
        </div>
      </template>

      <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start">
        <BaseLabel>Description</BaseLabel>
        <div class="mt-1 sm:mt-0 sm:col-span-2">
          <BaseTextarea
            v-model="form.description"
            placeholder="Enter product description"
            rows="3"
          ></BaseTextarea>
        </div>
      </div>
    </div>

    <div class="pt-5">
      <div class="flex justify-end">
        <SecondaryButton type="button">Cancel</SecondaryButton>
        <BaseButton type="submit">Save</BaseButton>
      </div>
    </div>
  </form>
</template>

On gists

14 always use computed instead of functions

cdruc vue

old.vue #

<script setup>
import {ref, computed} from "vue";

const people = ref([
  {id: 1, firstName: 'Constantin', lastName: 'Druc'},
  {id: 2, firstName: 'Jack', lastName: 'Dorsey'},
  {id: 3, firstName: 'Bill', lastName: 'Burr'},
  {id: 4, firstName: 'Hugh', lastName: 'Jackman'},
  {id: 5, firstName: 'Tracey', lastName: 'Johnes'},
]);

function fullName(firstName, lastName) {
  console.log('fullName() was executed');
  return firstName + ' ' + lastName;
}
</script>

<template>
  <ul>
    <li v-for="person in people" :key="person.id">
      {{ fullName(person.firstName, person.lastName) }}
    </li>
  </ul>
</template>

On gists

10 communication parent -> child, siblings

cdruc vue

ParentChild.vue #

<script setup>
import ShoppingCart from "./ShoppingCart.vue";
import {ref} from "vue";
 
const isOpened = ref(false);
</script>
 
<template>
  <button @click="isOpened = true">Open cart</button>
  <ShoppingCart :is-opened="isOpened" @toggle="(value) => isOpened = value"/>
</template>

On gists

08 Videoplayer (skeleton) Composition API

cdruc vue

VideoPlayer.vue #

<script setup>
import axios from "axios";
import Player from "@vimeo/player";
import {throttle} from "lodash";
import ForwardIcon from "./svgs/ForwardIcon";
import BackwardIcon from "./svgs/BackwardIcon";
import LoadingIcon from "./svgs/LoadingIcon";
import {computed, onMounted, onUnmounted, ref, useSlots} from "vue";

const props = defineProps({
  lessonId: Number,
  trackProgress: Boolean
});

const speedIndex = ref(1);
let player = null;
const speedOptions = [0.75, 1, 1.25, 1.5, 1.75, 2.0];
const currentSpeed = computed(() => speedOptions[speedIndex.value]);

if (window.localStorage.getItem('speedIndex')) {
  speedIndex.value = parseInt(window.localStorage.getItem('speedIndex'));
}

onMounted(() => {
  player = new Player(useSlots().default()[0].el);
  player.on('play', () => player.setPlaybackRate(currentSpeed.value));

  if (props.trackProgress) {
    player.on('progress', throttle((event) => updateProgress(event.percent), 10000));
    player.on('ended', () => updateProgress(1));
  }
});

onUnmounted(() => {
  player.off('play');
  player.off('progress');
  player.off('ended');
});

function updateProgress(percent) {
  return axios.put(`/lessons/${props.lessonId}/progress`, {percent: percent});
}

function toggleSpeed() {
  if (typeof speedOptions[speedIndex.value + 1] !== "undefined") {
    speedIndex.value += 1;
  } else {
    speedIndex.value = 0;
  }

  window.localStorage.setItem('speedIndex', speedIndex.value);
  player.setPlaybackRate(currentSpeed.value);
}

async function goForward(seconds) {
  const currentTime = await player.getCurrentTime();
  const totalDuration = await player.getDuration();
  player.setCurrentTime(Math.min(totalDuration, (currentTime + seconds)));
}

async function goBack(seconds) {
  const currentTime = await player.getCurrentTime();
  player.setCurrentTime(Math.max(0, (currentTime - seconds)));
}
</script>

<template>
  <div class="relative aspect-w-16 aspect-h-9">
    <div class="flex absolute inset-0 justify-center items-center">
      <LoadingIcon class="w-14 h-14 text-white animate-spin"></LoadingIcon>
    </div>

    <slot></slot>
  </div>

  <div class="flex justify-end">
    <div class="grid grid-cols-5 w-full max-w-xl divide-x divide-gray-700">
      <button
        class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
        @click="toggleSpeed()">
        <span class="hidden sm:inline-block">Speed:</span><span>{{ currentSpeed }}x</span>
      </button>

      <button
        class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
        @click="goBack(5)">
        <BackwardIcon class="w-4 h-4"/>
        <span>5s</span>
      </button>

      <button
        class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
        @click="goForward(5)">
        <span>5s</span>
        <ForwardIcon class="w-4 h-4"/>
      </button>

      <button
        class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
        @click="goBack(10)">
        <BackwardIcon class="w-4 h-4"/>
        <span>10s</span>
      </button>

      <button
        class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
        @click="goForward(10)">
        <span>10s</span>
        <ForwardIcon class="w-4 h-4"/>
      </button>
    </div>
  </div>
</template>

On gists

06 Loading script inside the Vue.js

cdruc vue

useScript.js #

import {onUnmounted} from "vue";

export default function (src) {
  return new Promise((resolve, reject) => {
    let script = document.querySelector(`script[src="${src}"]`);

    if (!script) {
      script = document.createElement("script");
      script.src = src;
      script.async = true;
      script.setAttribute("data-status", "loading");
      document.head.append(script);
    }

    if (script.getAttribute("data-status") === "loaded") {
      resolve();
    }

    function onScriptLoad() {
      resolve();
      script.setAttribute("data-status", "loaded");
    }

    function onScriptError() {
      reject();
      script.setAttribute("data-status", "error");
    }

    script.addEventListener("load", onScriptLoad);
    script.addEventListener("error", onScriptError);

    onUnmounted(() => {
      if (document.head.contains(script)) {
        script.removeEventListener("load", onScriptLoad);
        script.removeEventListener("error", onScriptError);
        document.head.removeChild(script);
      }
    })
  });
}