This commit is contained in:
2025-11-25 16:08:10 -05:00
parent cb0f972a5f
commit 72971f6d3e
19 changed files with 1595 additions and 785 deletions

View File

@@ -0,0 +1,343 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { defineProps, defineEmits } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Task {
id: string
name: string
points: number
is_good: boolean
image_id: string | null // Ensure image can be null or hold an object URL
}
const imageCacheName = 'images-v1'
const props = defineProps<{
taskIds: string[]
childId: string | number | null
isParentAuthenticated: boolean
}>()
const emit = defineEmits(['points-updated'])
const tasks = ref<Task[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const taskRefs = ref<Record<string, HTMLElement | null>>({})
const lastCenteredTaskId = ref<string | null>(null)
const lastCenterTime = ref<number>(0)
const readyTaskId = ref<string | null>(null)
const fetchTasks = async () => {
const taskPromises = props.taskIds.map((id) =>
fetch(`/api/task/${id}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}),
)
try {
const results = await Promise.all(taskPromises)
tasks.value = results
// Fetch images for each task (uses shared imageCache)
await Promise.all(tasks.value.map(fetchImage))
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
console.error('Error fetching tasks:', err)
} finally {
loading.value = false
}
}
const fetchImage = async (task: Task) => {
if (!task.image_id) {
console.log(`No image ID for task: ${task.id}`)
return
}
try {
const url = await getCachedImageUrl(task.image_id, imageCacheName)
task.image_id = url
} catch (err) {
console.error('Error fetching image for task', task.id, err)
}
}
const centerTask = async (taskId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = taskRefs.value[taskId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
const triggerTask = async (taskId: string) => {
if (!props.isParentAuthenticated || !props.childId) return
try {
const resp = await fetch(`/api/child/${props.childId}/trigger-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId }),
})
if (!resp.ok) return
const data = await resp.json()
emit('points-updated', { id: data.id, points: data.points })
} catch (err) {
console.error('Failed to trigger task:', err)
}
}
const handleTaskClick = async (taskId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = taskRefs.value[taskId]
if (!wrapper || !card) return
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible || lastCenteredTaskId.value !== taskId) {
// Center the task, but don't trigger
await centerTask(taskId)
lastCenteredTaskId.value = taskId
lastCenterTime.value = Date.now()
readyTaskId.value = taskId // <-- Add this line
return
}
// If already centered and visible, trigger the task
triggerTask(taskId)
readyTaskId.value = null
}
onMounted(fetchTasks)
// revoke all created object URLs when component unmounts
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div class="task-list-container">
<h3>Tasks</h3>
<div v-if="loading" class="loading">Loading tasks...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="tasks.length === 0" class="empty">No tasks</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="task-scroll">
<div
v-for="task in tasks"
:key="task.id"
class="task-card"
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
:ref="(el) => (taskRefs[task.id] = el)"
@click="() => handleTaskClick(task.id)"
>
<div class="task-name">{{ task.name }}</div>
<img v-if="task.image_id" :src="task.image_id" alt="Task Image" class="task-image" />
<div
class="task-points"
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
>
{{ task.is_good ? task.points : -task.points }} pts
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.task-list-container {
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
color: white;
width: 100%;
max-width: 600px;
box-sizing: border-box;
}
.task-list-container h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
font-weight: 600;
}
.loading,
.error,
.empty {
text-align: center;
padding: 1rem;
font-size: 0.9rem;
opacity: 0.8;
}
.error {
color: #ff6b6b;
}
.scroll-wrapper {
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
width: 100%;
-webkit-overflow-scrolling: touch;
}
/* Modern scrollbar styling */
.scroll-wrapper::-webkit-scrollbar {
height: 8px;
}
.scroll-wrapper::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8));
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.08);
}
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1));
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
}
.task-scroll {
display: flex;
gap: 0.75rem;
min-width: min-content;
padding: 0.5rem 0;
}
.task-card {
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 0.75rem;
min-width: 110px;
text-align: center;
flex-shrink: 0;
transition: all 0.2s ease;
border: 2px solid rgba(255, 255, 255, 0.15);
box-sizing: border-box;
}
/* Outline colors depending on is_good */
.task-card.good {
border-color: rgba(46, 204, 113, 0.9); /* green */
background: rgba(46, 204, 113, 0.06);
}
.task-card.bad {
border-color: rgba(255, 99, 71, 0.95); /* red */
background: rgba(255, 99, 71, 0.03);
}
.task-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
}
.task-card.ready {
box-shadow:
0 0 0 3px #667eea88,
0 0 12px #667eea44;
border-color: #667eea;
animation: ready-glow 0.7s;
}
@keyframes ready-glow {
0% {
box-shadow: 0 0 0 0 #667eea00;
border-color: inherit;
}
100% {
box-shadow:
0 0 0 3px #667eea88,
0 0 12px #667eea44;
border-color: #667eea;
}
}
.task-name {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.4rem;
word-break: break-word;
line-height: 1.2;
color: #fff;
}
.task-points {
font-size: 0.75rem;
font-weight: 700;
}
.task-points.good-points {
color: #2ecc71;
}
.task-points.bad-points {
color: #ff6b6b;
}
/* Image styling */
.task-image {
width: 70px;
height: 70px;
object-fit: cover;
border-radius: 6px;
margin: 0 auto 0.4rem auto;
display: block;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.task-list-container {
padding: 0.75rem;
max-width: 100%;
}
.task-card {
min-width: 90px;
padding: 0.6rem;
}
.task-name {
font-size: 0.8rem;
}
.task-image {
width: 50px;
height: 50px;
margin: 0 auto 0.3rem auto;
}
.task-points {
font-size: 0.72rem;
}
.scroll-wrapper::-webkit-scrollbar {
height: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
border-width: 1px;
}
}
</style>

View File

@@ -0,0 +1,320 @@
<script setup lang="ts">
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '@/components/ImagePicker.vue'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const emit = defineEmits<{
(e: 'updated'): void
}>()
// Define props
const props = defineProps<{
id?: string
}>()
const name = ref('')
const points = ref(0)
const isGood = ref(true)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Load task if editing
onMounted(async () => {
if (isEdit.value) {
loading.value = true
try {
const resp = await fetch(`/api/task/${props.id}`)
if (!resp.ok) throw new Error('Failed to load task')
const data = await resp.json()
name.value = data.name
points.value = Number(data.points) || 0
isGood.value = data.is_good
selectedImageId.value = data.image_id
} catch (e) {
error.value = 'Could not load task.'
} finally {
loading.value = false
// Delay focus until after DOM updates and event propagation
await nextTick()
nameInput.value?.focus()
}
} else {
// For create, also use nextTick
await nextTick()
nameInput.value?.focus()
}
})
const submit = async () => {
let imageId = selectedImageId.value
error.value = null
if (!name.value.trim()) {
error.value = 'Task name is required.'
return
}
if (points.value < 1) {
error.value = 'Points must be at least 1.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '2')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
loading.value = false
return
}
}
// Now update or create the task
try {
let resp
if (isEdit.value) {
resp = await fetch(`/api/task/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
points: points.value,
is_good: isGood.value,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/task/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
points: points.value,
is_good: isGood.value,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save task')
emit('updated')
await router.push({ name: 'TaskView' })
} catch (err) {
alert('Failed to save task.')
}
loading.value = false
}
function handleCancel() {
router.back()
}
// Handle new image from ImagePicker
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
</script>
<template>
<div class="task-edit-view">
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2>
<div v-if="loading" class="loading-message">Loading task...</div>
<form v-else @submit.prevent="submit" class="task-form">
<label>
Task Name
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
</label>
<label>
Task Points
<input v-model.number="points" type="number" min="1" max="100" required />
</label>
<label>
Task Type
<div class="good-bad-toggle">
<button
type="button"
:class="['toggle-btn', isGood ? 'good-active' : '']"
@click="isGood = true"
>
Good
</button>
<button
type="button"
:class="['toggle-btn', !isGood ? 'bad-active' : '']"
@click="isGood = false"
>
Bad
</button>
</div>
</label>
<div class="form-group image-picker-group">
<ImagePicker
id="task-image"
v-model="selectedImageId"
:image-type="2"
@add-image="onAddImage"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" @click="handleCancel" :disabled="loading">Cancel</button>
<button type="submit" :disabled="loading">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<style scoped>
.task-edit-view {
max-width: 400px;
width: 100%;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px #667eea22;
padding: 2rem 2.2rem 1.5rem 2.2rem;
margin: 0 auto;
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
}
.task-form {
display: flex;
flex-direction: column;
min-height: 0;
}
h2 {
text-align: center;
margin-bottom: 1.5rem;
color: #667eea;
}
.task-form label {
display: block;
margin-bottom: 1.1rem;
font-weight: 500;
color: #444;
width: 100%;
}
.task-form input[type='text'],
.task-form input[type='number'] {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.5rem;
border-radius: 7px;
border: 1px solid #cbd5e1;
font-size: 1rem;
background: #f8fafc;
box-sizing: border-box;
}
button[type='submit'] {
background: #667eea;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem 1.4rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: background 0.18s;
}
button[type='submit']:hover:not(:disabled) {
background: #5a67d8;
}
button[type='button'] {
background: #f3f3f3;
color: #666;
border: none;
border-radius: 8px;
padding: 0.6rem 1.4rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
}
.good-bad-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1.1rem;
justify-content: flex-start;
}
button.toggle-btn {
flex: 1 1 0;
padding: 0.5rem 1.2rem;
border-width: 2px;
border-radius: 7px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
background: #f3f3f3;
color: #444;
transition:
background 0.18s,
color 0.18s,
border-style 0.18s;
outline: none;
border-style: outset; /* Default style */
border-color: #cbd5e1;
}
button.toggle-btn.good-active {
background: #38c172;
color: #fff;
box-shadow: 0 2px 8px #38c17233;
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: #38c172;
}
button.toggle-btn.bad-active {
background: #e53e3e;
color: #fff;
box-shadow: 0 2px 8px #e53e3e33;
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: #e53e3e;
}
.actions {
margin-top: 1.2rem;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.error {
color: #e53e3e;
margin-top: 0.7rem;
text-align: center;
}
.loading-message {
text-align: center;
color: #666;
margin-bottom: 1.2rem;
}
</style>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
const props = defineProps<{
childId?: string | number
assignable?: boolean
deletable?: boolean
}>()
const emit = defineEmits(['edit-task', 'delete-task'])
const tasks = ref<
{
id: string
name: string
points: number
is_good: boolean
image_id?: string | null
image_url?: string | null
}[]
>([])
const loading = ref(true)
const error = ref<string | null>(null)
const fetchTasks = async () => {
loading.value = true
error.value = null
let url = ''
if (props.childId) {
if (props.assignable) {
url = `/api/child/${props.childId}/list-assignable-tasks`
} else {
url = `/api/child/${props.childId}/list-tasks`
}
} else {
url = '/api/task/list'
}
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const taskList = data.tasks || []
// Fetch images for each task if image_id is present
await Promise.all(
taskList.map(async (task: any) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
} catch (e) {
task.image_url = null
}
}
}),
)
tasks.value = taskList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
tasks.value = []
} finally {
loading.value = false
}
}
onMounted(fetchTasks)
watch(() => [props.childId, props.assignable], fetchTasks)
const handleEdit = (taskId: string) => {
emit('edit-task', taskId)
}
const handleDelete = (taskId: string) => {
emit('delete-task', taskId)
}
defineExpose({ refresh: fetchTasks })
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
const listHeight = computed(() => {
// Add a little for padding, separators, etc.
const n = tasks.value.length
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
})
</script>
<template>
<div class="task-listbox" :style="{ maxHeight: `min(${listHeight}, calc(100vh - 4.5rem))` }">
<div v-if="loading" class="loading">Loading tasks...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="tasks.length === 0" class="empty">No tasks found.</div>
<div v-else>
<div v-for="(task, idx) in tasks" :key="task.id">
<div
class="task-list-item"
:class="{ good: task.is_good, bad: !task.is_good }"
@click="handleEdit(task.id)"
>
<img v-if="task.image_url" :src="task.image_url" alt="Task" class="task-image" />
<span class="task-name">{{ task.name }}</span>
<span class="task-points">
{{ task.is_good ? task.points : '-' + task.points }} pts
</span>
<button
v-if="props.deletable"
class="delete-btn"
@click.stop="handleDelete(task.id)"
aria-label="Delete task"
type="button"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < tasks.length - 1" class="task-separator"></div>
</div>
</div>
</div>
</template>
<style scoped>
.task-listbox {
flex: 1 1 auto;
width: 100%;
max-width: 480px;
/* Subtract any header/nav height if needed, e.g. 4.5rem */
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
background: #fff5;
padding: 0.2rem 0.2rem 0.2rem;
border-radius: 12px;
}
.task-list-item {
display: flex;
align-items: center;
border: 2px outset #38c172;
border-radius: 8px;
padding: 0.2rem 1rem;
background: #f8fafc;
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
}
.task-list-item.bad {
border-color: #e53e3e;
background: #fff5f5;
}
.task-list-item.good {
border-color: #38c172;
background: #f0fff4;
}
.task-image {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: #eee;
flex-shrink: 0;
}
.task-name {
flex: 1;
text-align: left;
}
.task-points {
min-width: 60px;
text-align: right;
font-weight: 600;
}
.loading,
.error,
.empty {
margin: 1.2rem 0;
color: #888;
}
.error {
color: #e53e3e;
}
.task-list-item:last-child {
margin-bottom: 0;
}
.task-separator {
height: 0px;
background: #0000;
margin: 0rem 0.2rem;
border-radius: 0px;
}
.delete-btn {
background: transparent;
border: none;
border-radius: 50%;
padding: 0.15rem;
margin-left: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
transition:
background 0.15s,
box-shadow 0.15s;
width: 2rem;
height: 2rem;
opacity: 0.92;
}
.delete-btn:hover {
background: #ffeaea;
box-shadow: 0 0 0 2px #ef444422;
opacity: 1;
}
.delete-btn svg {
display: block;
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div class="task-view">
<TaskList
ref="taskListRef"
:deletable="true"
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
@delete-task="confirmDeleteTask"
/>
<!-- Floating Action Button -->
<button class="fab" @click="createTask" aria-label="Create Task">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div v-if="showConfirm" class="modal-backdrop">
<div class="modal">
<p>Are you sure you want to delete this task?</p>
<div class="actions">
<button @click="deleteTask">Yes, Delete</button>
<button @click="showConfirm = false">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import TaskList from './TaskList.vue'
const $router = useRouter()
const showConfirm = ref(false)
const taskToDelete = ref<string | null>(null)
const taskListRef = ref()
function confirmDeleteTask(taskId: string) {
taskToDelete.value = taskId
showConfirm.value = true
}
const deleteTask = async () => {
if (!taskToDelete.value) return
try {
const resp = await fetch(`/api/task/${taskToDelete.value}`, {
method: 'DELETE',
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
// Refresh the task list after successful delete
taskListRef.value?.refresh()
console.log(`Task ${taskToDelete.value} deleted successfully`)
} catch (err) {
console.error('Failed to delete task:', err)
} finally {
showConfirm.value = false
taskToDelete.value = null
}
}
// New function to handle task creation
const createTask = () => {
// Route to your create task page or open a create dialog
// Example:
$router.push({ name: 'CreateTask' })
}
</script>
<style scoped>
.task-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.modal {
background: #fff;
color: #222;
padding: 1.5rem 2rem;
border-radius: 12px;
min-width: 240px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
text-align: center;
}
.actions {
margin-top: 1.2rem;
display: flex;
gap: 1rem;
justify-content: center;
}
.actions button {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
}
.actions button:first-child {
background: #ef4444;
color: #fff;
}
.actions button:last-child {
background: #f3f3f3;
color: #666;
}
.actions button:last-child:hover {
background: #e2e8f0;
}
/* Floating Action Button styles */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #667eea;
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: #5a67d8;
}
</style>