perbaikan dashboard

This commit is contained in:
aji@catalis.app
2025-07-20 23:46:17 +07:00
parent 93a7261188
commit f0dbf419f3
4 changed files with 556 additions and 182 deletions

View File

@@ -0,0 +1,118 @@
<script lang="ts">
export let name: string;
export let colorClass: string = "text-gray-500"; // Default color
// This is a simplified way. For a real app, you might import from a library or
// have a map of names to actual SVG paths.
// For now, we'll keep the direct SVG paths as in your original component,
// but just moved into this new file.
</script>
<div class={`w-8 h-8 mb-2 ${colorClass}`}>
{#if name === "exclamation-triangle"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{:else if name === "folder"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7h4l2 3h10a1 1 0 011 1v6a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg>
{:else if name === "document"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16h8M8 12h8m-6-8h4a2 2 0 012 2v12a2 2 0 01-2 2h-4a2 2 0 01-2-2V6a2 2 0 012-2z"
/>
</svg>
{:else if name === "home"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7m-9 2v6m0 0H5a2 2 0 01-2-2v-4a2 2 0 012-2h3m4 6h4a2 2 0 002-2v-4a2 2 0 00-2-2h-3"
/>
</svg>
{:else if name === "clock"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6l4 2m-6 8a9 9 0 100-18 9 9 0 000 18z"
/>
</svg>
{:else if name === "cube"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4v10l8 4 8-4V7z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v18"
/>
</svg>
{:else if name === "building-storefront"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4h16v4H4V4zm0 4v12h16V8m-2 4h-4v4h4v-4z"
/>
</svg>
{:else if name === "user-group"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 11a4 4 0 11-8 0 4 4 0 018 0zm-6 5a6.002 6.002 0 00-5.775 4.5A10.001 10.001 0 0112 21a10.001 10.001 0 017.775-5.5A6.002 6.002 0 0012 16h-2z"
/>
</svg>
{:else if name === "truck"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 16v1a2 2 0 002 2h14a2 2 0 002-2v-1m-1-3V7a2 2 0 00-2-2H7l-4 4v6m18-6h1a1 1 0 011 1v3a1 1 0 01-1 .993L20 .993V7zM5.5 17a1.5 1.5 0 11-3 .001A1.5 1.5 0 015.517zM18.5,17a1.5,1.5,0,1,1,3,.001A1.5,1.5,0,0,1,18.517,17Z"
/>
</svg>
{:else if name === "utensils"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8V3m0 5a2 2 0 11-4 0 2 2 0 014 0zm0 0v10m0-10h4a2 2 0 012 2v6a2 2 0 01-2 2h-4a2 2 0 01-2-2V8a2 2 0 012-2z"
/>
</svg>
{:else if name === "comments"}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/if}
</div>

View File

@@ -0,0 +1,311 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
type ConditionType =
| "NEW"
| "GOOD"
| "AVERAGE"
| "POOR"
| "SUBSTANDARD"
| "BROKEN";
type InventoryInsert = {
item_name: string;
villa_id: string;
item_location: string;
brand_color_material: string;
condition: ConditionType;
remarks: string;
created_at: string;
updated_at: string;
};
type Villa = {
id: string;
villa_name: string;
};
export let showModal: boolean;
export let isEditing: boolean;
export let formData: InventoryInsert;
export let villaItems: Villa[];
export let isLoading: boolean = false;
const dispatch = createEventDispatcher();
let formErrors: Record<string, string> = {};
function validateForm(): boolean {
formErrors = {};
let isValid = true;
if (!formData.item_name) {
formErrors.item_name = "Item name is required.";
isValid = false;
}
if (!formData.villa_id) {
formErrors.villa_id = "Villa name is required.";
isValid = false;
}
if (!formData.item_location) {
formErrors.item_location = "Item location is required.";
isValid = false;
}
if (!formData.brand_color_material) {
formErrors.brand_color_material =
"Brand/Color/Material is required.";
isValid = false;
}
if (!formData.condition) {
formErrors.condition = "Condition is required.";
isValid = false;
}
if (formData.remarks && formData.remarks.length > 500) {
formErrors.remarks = "Remarks cannot exceed 500 characters.";
isValid = false;
}
return isValid;
}
function handleSubmit() {
if (validateForm()) {
dispatch("submit", formData);
}
}
function closeModal() {
formErrors = {}; // Clear errors when closing
dispatch("close");
}
</script>
{#if showModal}
<div
class="fixed inset-0 bg-black bg-opacity-50 z-50 overflow-y-auto flex items-center justify-center p-4"
on:click|self={closeModal}
>
<form
on:submit|preventDefault={handleSubmit}
class="w-full max-w-lg bg-white p-8 rounded-2xl shadow-2xl space-y-5 transform transition-all duration-300 ease-out scale-100 opacity-100"
>
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-gray-800">
{isEditing ? "Edit Item" : "Add New Item"}
</h2>
<button
type="button"
class="text-gray-500 hover:text-gray-700 p-2 rounded-full hover:bg-gray-100 transition"
on:click={closeModal}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div>
<label
for="item_name"
class="block text-sm font-medium text-gray-700 mb-1"
>Item Name</label
>
<input
type="text"
id="item_name"
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.item_name
? 'border-red-500'
: ''}"
bind:value={formData.item_name}
placeholder="e.g., Living Room Sofa"
required
/>
{#if formErrors.item_name}
<p class="text-red-500 text-xs mt-1">
{formErrors.item_name}
</p>
{/if}
</div>
<div>
<label
for="villa_id"
class="block text-sm font-medium text-gray-700 mb-1"
>Villa Name</label
>
<select
id="villa_id"
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.villa_id
? 'border-red-500'
: ''}"
bind:value={formData.villa_id}
required
>
<option value="" disabled>Select Villa</option>
{#each villaItems as villa}
<option value={villa.id}>
{villa.villa_name}
</option>
{/each}
</select>
{#if formErrors.villa_id}
<p class="text-red-500 text-xs mt-1">
{formErrors.villa_id}
</p>
{/if}
</div>
<div>
<label
for="item_location"
class="block text-sm font-medium text-gray-700 mb-1"
>Item Location</label
>
<input
type="text"
id="item_location"
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.item_location
? 'border-red-500'
: ''}"
bind:value={formData.item_location}
placeholder="e.g., Master Bedroom, Kitchen"
required
/>
{#if formErrors.item_location}
<p class="text-red-500 text-xs mt-1">
{formErrors.item_location}
</p>
{/if}
</div>
<div>
<label
for="brand_color_material"
class="block text-sm font-medium text-gray-700 mb-1"
>Brand/Color/Material</label
>
<input
type="text"
id="brand_color_material"
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.brand_color_material
? 'border-red-500'
: ''}"
bind:value={formData.brand_color_material}
placeholder="e.g., IKEA / White / Wood"
required
/>
{#if formErrors.brand_color_material}
<p class="text-red-500 text-xs mt-1">
{formErrors.brand_color_material}
</p>
{/if}
</div>
<div>
<label
for="condition"
class="block text-sm font-medium text-gray-700 mb-1"
>Condition</label
>
<select
id="condition"
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.condition
? 'border-red-500'
: ''}"
bind:value={formData.condition}
required
>
<option value="NEW">New</option>
<option value="GOOD">Good</option>
<option value="AVERAGE">Average</option>
<option value="POOR">Poor</option>
<option value="SUBSTANDARD">Substandard</option>
<option value="BROKEN">Broken</option>
</select>
{#if formErrors.condition}
<p class="text-red-500 text-xs mt-1">
{formErrors.condition}
</p>
{/if}
</div>
<div>
<label
for="remarks"
class="block text-sm font-medium text-gray-700 mb-1"
>Remarks</label
>
<textarea
id="remarks"
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.remarks
? 'border-red-500'
: ''}"
bind:value={formData.remarks}
placeholder="Optional remarks about the item"
rows="3"
></textarea>
{#if formErrors.remarks}
<p class="text-red-500 text-xs mt-1">
{formErrors.remarks}
</p>
{/if}
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white font-semibold px-4 py-3 rounded-lg hover:bg-blue-700 transition duration-200 shadow-md flex items-center justify-center gap-2"
disabled={isLoading}
>
{#if isLoading}
<svg
class="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Saving...
{:else}
{isEditing ? "Update Item" : "Add Item"}
{/if}
</button>
</form>
</div>
{/if}
<style>
/* Modal entry animation */
.transform {
transition: all 0.3s ease-out;
}
.scale-100 {
transform: scale(1);
}
.opacity-100 {
opacity: 1;
}
/* Initial state for hidden modal - you'd set this with a conditional class or just let Tailwind apply it on removal */
</style>

View File

@@ -0,0 +1,8 @@
export function debounce<T extends (...args: any[]) => void>(func: T, delay: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
}

View File

@@ -1,6 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import { supabase } from "$lib/supabaseClient";
import HeroIcon from "../../components/HeroIcon.svelte";
import { quintOut } from "svelte/easing";
import { tweened } from "svelte/motion";
import type { Tweened } from "svelte/motion";
type StatKey =
| "vb_purchase_orders"
@@ -15,6 +19,22 @@
| "vb_dinning"
| "vb_feedback";
// This object will hold our `tweened` store instances
const tweenedStats: Record<StatKey, Tweened<number>> = {
vb_purchase_orders: tweened(0),
vb_issues: tweened(0),
vb_villas: tweened(0),
vb_timesheet: tweened(0),
vb_inventory: tweened(0),
vb_projects: tweened(0),
vb_vendor: tweened(0),
vb_employee: tweened(0),
vb_transport: tweened(0),
vb_dinning: tweened(0),
vb_feedback: tweened(0),
};
// This object will hold the *values* from the tweened stores, and will be reactive
let stats: Record<StatKey, number> = {
vb_purchase_orders: 0,
vb_issues: 0,
@@ -29,277 +49,194 @@
vb_feedback: 0,
};
let isLoading = true;
const items: {
label: string;
key: StatKey;
color: string;
icon: string;
bgColor: string;
}[] = [
{
label: "Total Issue",
label: "Total Issues",
key: "vb_issues",
color: "text-red-600",
icon: "exclamation-triangle",
bgColor: "bg-red-100",
},
{
label: "Total Project",
label: "Total Projects",
key: "vb_projects",
color: "text-purple-600",
icon: "folder",
bgColor: "bg-purple-100",
},
{
label: "Total PO",
label: "Total POs",
key: "vb_purchase_orders",
color: "text-blue-600",
icon: "document",
bgColor: "bg-blue-100",
},
{
label: "Total Villa",
label: "Total Villas",
key: "vb_villas",
color: "text-green-600",
icon: "home",
bgColor: "bg-green-100",
},
{
label: "Total Timesheet",
label: "Total Timesheets",
key: "vb_timesheet",
color: "text-gray-800",
icon: "clock",
bgColor: "bg-gray-100",
},
{
label: "Total Inventories",
key: "vb_inventory",
color: "text-yellow-600",
icon: "cube",
bgColor: "bg-yellow-100",
},
{
label: "Total Vendor",
label: "Total Vendors",
key: "vb_vendor",
color: "text-orange-600",
icon: "building-storefront",
bgColor: "bg-orange-100",
},
{
label: "Total Employee",
label: "Total Employees",
key: "vb_employee",
color: "text-teal-600",
icon: "user-group",
bgColor: "bg-teal-100",
},
{
label: "Total Transport",
key: "vb_transport",
color: "text-indigo-600",
icon: "truck",
bgColor: "bg-indigo-100",
},
{
label: "Total Dinning",
label: "Total Dining",
key: "vb_dinning",
color: "text-pink-600",
icon: "utensils",
bgColor: "bg-pink-100",
},
{
label: "Total Feedback",
key: "vb_feedback",
color: "text-gray-600",
icon: "comments",
bgColor: "bg-gray-100",
},
];
onMount(async () => {
const fetchCount = async (table: string) => {
const { count } = await supabase
const { count, error } = await supabase
.from(table)
.select("*", { count: "exact", head: true });
if (error) {
console.error(
`Error fetching count for ${table}:`,
error.message,
);
return 0;
}
return count || 0;
};
for (const key of Object.keys(stats) as StatKey[]) {
stats[key] = await fetchCount(key);
}
const promises = items.map(async (item) => {
const count = await fetchCount(item.key);
// Set the tweened store's value
tweenedStats[item.key].set(count, {
duration: 1000,
easing: quintOut,
});
console.log("Dashboard data fetched successfully");
// Subscribe to the tweened store and update the 'stats' variable
// Using a reactive statement `$: stats[item.key] = $tweenedStats[item.key]`
// for each item in the loop is not ideal.
// A better way is to update 'stats' directly within the subscription,
// or just bind to `tweenedStats[item.key]` in the template.
// For simplicity and direct reactivity:
tweenedStats[item.key].subscribe((val) => {
stats = { ...stats, [item.key]: Math.round(val) };
});
});
await Promise.all(promises);
isLoading = false;
console.log("Dashboard data fetched successfully and animated!");
});
</script>
<!-- You can extract SVGs from https://heroicons.com or install them via a package for better maintainability -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1
class="text-3xl font-extrabold text-gray-900 mb-8 text-center sm:text-left"
>
Dashboard Overview
</h1>
<div class="max-w-7xl mx-auto">
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{#each items as item}
<div
class="bg-white p-6 rounded-xl shadow text-center flex flex-col items-center"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100
flex flex-col items-center justify-center text-center
transform transition-all duration-300 ease-in-out
hover:scale-105 hover:shadow-xl hover:border-blue-300
{isLoading ? 'animate-pulse-light' : ''}"
>
<!-- ICON -->
{#if item.icon === "exclamation-triangle"}
<svg
class="w-8 h-8 mb-2 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{:else if item.icon === "folder"}
<svg
class="w-8 h-8 mb-2 text-purple-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7h4l2 3h10a1 1 0 011 1v6a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg>
{:else if item.icon === "document"}
<svg
class="w-8 h-8 mb-2 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16h8M8 12h8m-6-8h4a2 2 0 012 2v12a2 2 0 01-2 2h-4a2 2 0 01-2-2V6a2 2 0 012-2z"
/>
</svg>
{:else if item.icon === "home"}
<svg
class="w-8 h-8 mb-2 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7m-9 2v6m0 0H5a2 2 0 01-2-2v-4a2 2 0 012-2h3m4 6h4a2 2 0 002-2v-4a2 2 0 00-2-2h-3"
/>
</svg>
{:else if item.icon === "clock"}
<svg
class="w-8 h-8 mb-2 text-gray-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6l4 2m-6 8a9 9 0 100-18 9 9 0 000 18z"
/>
</svg>
{:else if item.icon === "cube"}
<svg
class="w-8 h-8 mb-2 text-yellow-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4v10l8 4 8-4V7z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v18"
/>
</svg>
{:else if item.icon === "building-storefront"}
<svg
class="w-8 h-8 mb-2 text-orange-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4h16v4H4V4zm0 4v12h16V8m-2 4h-4v4h4v-4z"
/>
</svg>
{:else if item.icon === "user-group"}
<svg
class="w-8 h-8 mb-2 text-teal-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 11a4 4 0 11-8 0 4 4 0 018 0zm-6 5a6.002 6.002 0 00-5.775 4.5A10.001 10.001 0 0112 21a10.001 10.001 0 017.775-5.5A6.002 6.002 0 0012 16h-2z"
/>
</svg>
{:else if item.icon === "truck"}
<svg
class="w-8 h-8 mb-2 text-indigo-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 16v1a2 2 0 002 2h14a2 2 0 002-2v-1m-1-3V7a2 2 0 00-2-2H7l-4 4v6m18-6h1a1 1 0 011 1v3a1 1 0 01-1 .993L20 .993V7zM5.5 17a1.5 1.5 0 11-3 .001A1.5 1.5 0 015.517zM18.5,17a1.5,1.5,0,1,1,3,.001A1.5,1.5,0,0,1,18.517,17Z"
/>
</svg>
{:else if item.icon === "utensils"}
<svg
class="w-8 h-8 mb-2 text-pink-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8V3m0 5a2 2 0 11-4 0 2 2 0 014 0zm0 0v10m0-10h4a2 2 0 012 2v6a2 2 0 01-2 2h-4a2 2 0 01-2-2V8a2 2 0 012-2z"
/>
</svg>
{:else if item.icon === "comments"}
<svg
class="w-8 h-8 mb-2 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/if}
<div
class="relative w-14 h-14 rounded-full {item.bgColor}
flex items-center justify-center mb-4 transition-all duration-300 ease-in-out
group-hover:scale-110"
>
<HeroIcon name={item.icon} colorClass={item.color} />
<div
class="absolute inset-0 rounded-full ring-2 ring-transparent transition-all duration-300 ease-in-out
group-hover:ring-blue-200"
></div>
</div>
<!-- LABEL & VALUE -->
<h2 class="text-lg font-semibold text-gray-700 mb-1">
<h2
class="text-md font-semibold text-gray-700 mb-2 whitespace-nowrap"
>
{item.label}
</h2>
<p class={`text-2xl font-bold ${item.color}`}>
{stats[item.key]}
<p class={`text-3xl font-extrabold ${item.color}`}>
{#if isLoading}
<div class="h-8 bg-gray-200 rounded w-24 mx-auto"></div>
{:else}
{stats[item.key]}
{/if}
</p>
</div>
{/each}
</div>
</div>
<style>
/* Custom animations for a smoother feel */
@keyframes pulse-light {
0%,
100% {
background-color: #ffffff; /* white */
}
50% {
background-color: #f3f4f6; /* gray-100 */
}
}
.animate-pulse-light {
animation: pulse-light 2s infinite ease-in-out;
}
</style>