mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
Replace FullCalendar with custom calendar UI
This commit is contained in:
283
resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue
Normal file
283
resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
|
||||
import type { DayEvent, ActivityBox } from './calendarTypes';
|
||||
import type { WindowActivityInPeriod } from './activityTypes';
|
||||
|
||||
defineProps<{
|
||||
dayStr: string;
|
||||
totalGridHeight: number;
|
||||
hasActivityStatus: boolean;
|
||||
|
||||
// Events
|
||||
dayEvents: DayEvent[];
|
||||
getEventStyle: (dayEvent: DayEvent, dayStr: string) => Record<string, string>;
|
||||
getEventOpacityClass: (dayEvent: DayEvent, dayStr: string) => string;
|
||||
getEventDurationSeconds: (dayEvent: DayEvent, dayStr: string) => number;
|
||||
|
||||
// Drag state
|
||||
isDragging: boolean;
|
||||
dragEventId: string | null;
|
||||
dragPreview: Record<string, string> | undefined;
|
||||
|
||||
// Resize state
|
||||
resizeEventId: string | null;
|
||||
resizeCrossDayPreview: Record<string, string> | undefined;
|
||||
|
||||
// Now indicator
|
||||
showNowIndicator: boolean;
|
||||
nowIndicatorTop: number;
|
||||
|
||||
// Activity boxes
|
||||
activityBoxes: ActivityBox[];
|
||||
getActivityBoxLabel: (box: ActivityBox) => string;
|
||||
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
|
||||
getActivityPercentage: (count: number, total: number) => string;
|
||||
getActivityText: (activity: WindowActivityInPeriod) => string;
|
||||
|
||||
// Selection
|
||||
showSelection: boolean;
|
||||
isSelectionStart: boolean;
|
||||
isSelectionIntermediate: boolean;
|
||||
isSelectionEnd: boolean;
|
||||
selectionTop: number;
|
||||
selectionHeight: number;
|
||||
selectionEndTop: number;
|
||||
selectionEndHeight: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
|
||||
(e: 'event-keydown-enter', dayEvent: DayEvent): void;
|
||||
(
|
||||
e: 'resizer-pointerdown',
|
||||
event: PointerEvent,
|
||||
dayEvent: DayEvent,
|
||||
edge: 'start' | 'end'
|
||||
): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
|
||||
:class="{
|
||||
'has-activity-status': hasActivityStatus,
|
||||
}"
|
||||
:data-date="dayStr"
|
||||
:style="{ height: totalGridHeight + 'px' }">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0.5 right-0.5"
|
||||
:class="{ 'fc-events-inset': hasActivityStatus }">
|
||||
<div
|
||||
v-for="dayEvent in dayEvents"
|
||||
:key="dayEvent.event.id"
|
||||
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card overflow-hidden border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
:class="[
|
||||
getEventOpacityClass(dayEvent, dayStr),
|
||||
{
|
||||
'running-entry rounded-b-none': dayEvent.event.isRunning,
|
||||
'fc-event-dragging': isDragging && dragEventId === dayEvent.event.id,
|
||||
'fc-event-resizing': resizeEventId === dayEvent.event.id,
|
||||
'rounded-t-none': dayEvent.isClippedStart,
|
||||
'rounded-b-none': dayEvent.isClippedEnd,
|
||||
'fc-event-clipped-start': dayEvent.isClippedStart,
|
||||
'fc-event-clipped-end': dayEvent.isClippedEnd,
|
||||
},
|
||||
]"
|
||||
:data-event-id="dayEvent.event.id"
|
||||
:style="getEventStyle(dayEvent, dayStr)"
|
||||
tabindex="0"
|
||||
:aria-label="dayEvent.event.title"
|
||||
role="button"
|
||||
@pointerdown="emit('event-pointerdown', $event, dayEvent)"
|
||||
@keydown.enter.prevent="
|
||||
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
|
||||
">
|
||||
<div
|
||||
v-if="!dayEvent.isClippedStart"
|
||||
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
@pointerdown.stop.prevent="
|
||||
emit('resizer-pointerdown', $event, dayEvent, 'start')
|
||||
"></div>
|
||||
<div class="px-1 py-0.5 h-full overflow-hidden">
|
||||
<FullCalendarEventContent
|
||||
:title="dayEvent.event.title"
|
||||
:project-name="dayEvent.event.project?.name"
|
||||
:task-name="dayEvent.event.task?.name"
|
||||
:client-name="dayEvent.event.client?.name"
|
||||
:duration-seconds="getEventDurationSeconds(dayEvent, dayStr)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!dayEvent.event.isRunning && !dayEvent.isClippedEnd"
|
||||
class="fc-event-resizer fc-event-resizer-end absolute z-[99] w-full h-3 left-0 bottom-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
@pointerdown.stop.prevent="
|
||||
emit('resizer-pointerdown', $event, dayEvent, 'end')
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showNowIndicator"
|
||||
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
|
||||
:style="{ top: nowIndicatorTop + 'px' }"></div>
|
||||
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="activity-status-box"
|
||||
:class="abox.isIdle ? 'idle' : 'active'"
|
||||
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" :side-offset="8">
|
||||
<template v-if="getActivityBoxActivities(abox).length === 0">
|
||||
{{ getActivityBoxLabel(abox) }}
|
||||
</template>
|
||||
<div v-else class="max-w-[300px]">
|
||||
<div class="font-semibold mb-2">{{ getActivityBoxLabel(abox) }}</div>
|
||||
<div
|
||||
v-for="(activity, actIdx) in getActivityBoxActivities(abox).slice(0, 5)"
|
||||
:key="actIdx"
|
||||
class="mt-1 text-[11px] opacity-90 flex items-center gap-1.5">
|
||||
<img
|
||||
v-if="activity.icon"
|
||||
:src="activity.icon"
|
||||
:alt="activity.appName"
|
||||
class="w-4 h-4 rounded-sm shrink-0" />
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 rounded-sm bg-white/10 flex items-center justify-center text-[8px] shrink-0">
|
||||
{{ activity.appName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{
|
||||
getActivityPercentage(
|
||||
activity.count,
|
||||
getActivityBoxActivities(abox).reduce(
|
||||
(sum, a) => sum + a.count,
|
||||
0
|
||||
)
|
||||
)
|
||||
}}%
|
||||
{{ getActivityText(activity) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="getActivityBoxActivities(abox).length > 5"
|
||||
class="mt-1 text-[11px] opacity-70 italic">
|
||||
...and {{ getActivityBoxActivities(abox).length - 5 }} more
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<div
|
||||
v-if="showSelection && isSelectionStart"
|
||||
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
|
||||
:style="{
|
||||
top: selectionTop + 'px',
|
||||
height: selectionHeight + 'px',
|
||||
}"></div>
|
||||
<div
|
||||
v-if="showSelection && isSelectionIntermediate"
|
||||
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
|
||||
:style="{
|
||||
top: '0px',
|
||||
height: totalGridHeight + 'px',
|
||||
}"></div>
|
||||
<div
|
||||
v-if="showSelection && isSelectionEnd"
|
||||
class="absolute inset-x-0 pointer-events-none bg-accent border border-primary z-[2]"
|
||||
:style="{
|
||||
top: selectionEndTop + 'px',
|
||||
height: selectionEndHeight + 'px',
|
||||
}"></div>
|
||||
|
||||
<div
|
||||
v-if="isDragging && dragPreview"
|
||||
class="fc-cross-day-preview pointer-events-none mx-px"
|
||||
:style="dragPreview"></div>
|
||||
|
||||
<div
|
||||
v-if="resizeCrossDayPreview"
|
||||
class="fc-cross-day-preview pointer-events-none mx-px"
|
||||
:style="resizeCrossDayPreview"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fc-event-resizer::after {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.fc-event-resizer:hover::after {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.fc-event-resizing,
|
||||
.fc-event-resizing .fc-event-resizer {
|
||||
cursor: row-resize !important;
|
||||
}
|
||||
.fc-event-resizing {
|
||||
box-shadow: var(--theme-shadow-dropdown);
|
||||
}
|
||||
.fc-event-resizing .fc-event-resizer {
|
||||
opacity: 1;
|
||||
}
|
||||
.fc-event-resizing .fc-event-resizer::after {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.running-entry .fc-event-resizer-end {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fc-timegrid-now-indicator-line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.activity-status-box {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
cursor: default;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.activity-status-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.activity-status-box.idle::before {
|
||||
background-color: rgba(156, 163, 175, 0.1);
|
||||
}
|
||||
.activity-status-box.idle:hover::before {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
.activity-status-box.active::before {
|
||||
background-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.activity-status-box.active:hover::before {
|
||||
background-color: rgba(34, 197, 94, 1);
|
||||
}
|
||||
|
||||
.fc-events-inset {
|
||||
left: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user