215 lines
		
	
	
		
			5.5 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			215 lines
		
	
	
		
			5.5 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <script setup>
 | |
| import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
 | |
| import { fromLocalString } from '@/utils/date'
 | |
| 
 | |
| const props = defineProps({
 | |
|   day: Object,
 | |
|   dragging: { type: Boolean, default: false },
 | |
| })
 | |
| 
 | |
| // Reactive viewport width detection
 | |
| const isNarrowView = ref(false)
 | |
| const isVeryNarrowView = ref(false)
 | |
| const isSmallView = ref(false)
 | |
| 
 | |
| function checkViewportWidth() {
 | |
|   const width = window.innerWidth
 | |
|   isSmallView.value = width < 800
 | |
|   isNarrowView.value = width < 600
 | |
|   isVeryNarrowView.value = width < 400
 | |
| }
 | |
| 
 | |
| onMounted(() => {
 | |
|   checkViewportWidth()
 | |
|   window.addEventListener('resize', checkViewportWidth)
 | |
| })
 | |
| 
 | |
| onBeforeUnmount(() => {
 | |
|   window.removeEventListener('resize', checkViewportWidth)
 | |
| })
 | |
| 
 | |
| const formattedDate = computed(() => {
 | |
|   const date = fromLocalString(props.day.date)
 | |
|   
 | |
|   let options = { day: 'numeric', month: 'short' }
 | |
|   
 | |
|   if (isVeryNarrowView.value) {
 | |
|     // Very narrow: show only day number
 | |
|     options = { day: 'numeric' }
 | |
|   } else if (isNarrowView.value) {
 | |
|     // Narrow: show day and month, no weekday
 | |
|     options = { day: 'numeric', month: 'short' }
 | |
|   } else {
 | |
|     // Wide: show weekday, day, and month
 | |
|     options = { weekday: 'short', day: 'numeric', month: 'short' }
 | |
|   }
 | |
|   
 | |
|   let formatted = date.toLocaleDateString(undefined, options)
 | |
|   
 | |
|   // Below 700px, replace first space with newline to force weekday on separate line
 | |
|   if (isSmallView.value && !isNarrowView.value && !isVeryNarrowView.value) {
 | |
|     formatted = formatted.replace(/\s/, '\n')
 | |
|   }
 | |
|   
 | |
|   // Replace the last space (between month and day) with nbsp to prevent breaking there
 | |
|   // but keep the space after weekday (if present) as regular space to allow wrapping
 | |
|   formatted = formatted.replace(/\s+(?=\S+$)/, '\u00A0')
 | |
|   
 | |
|   return formatted
 | |
| })
 | |
| </script>
 | |
| 
 | |
| <template>
 | |
|   <div
 | |
|     class="cell"
 | |
|     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
 | |
|     :class="[props.day.monthClass, { today: props.day.isToday, weekend: props.day.isWeekend, firstday: props.day.isFirstDay, selected: props.day.isSelected, holiday: props.day.isHoliday }]"
 | |
|     :data-date="props.day.date"
 | |
|   >
 | |
|     <span class="compact-date">{{ formattedDate }}</span>
 | |
|     <h1 class="day-number">{{ props.day.displayText }}</h1>
 | |
|     <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span>
 | |
|     <div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name">
 | |
|       {{ props.day.holiday.name }}
 | |
|     </div>
 | |
|   </div>
 | |
| </template>
 | |
| 
 | |
| <style scoped>
 | |
| .cell {
 | |
|   position: relative;
 | |
|   user-select: none;
 | |
|   display: grid;
 | |
|   grid-template-columns: 1fr;
 | |
|   grid-template-rows: 1fr auto;
 | |
|   grid-template-areas:
 | |
|     'day-number'
 | |
|     'holiday-info';
 | |
|   padding: 0.25em;
 | |
|   overflow: visible;
 | |
|   width: 100%;
 | |
|   height: var(--row-h);
 | |
|   font-weight: 700;
 | |
|   transition: background-color 0.15s ease;
 | |
|   align-items: center;
 | |
|   justify-items: center;
 | |
| }
 | |
| .cell h1.day-number {
 | |
|   position: absolute;
 | |
|   font-size: 5vmin;
 | |
|   font-weight: 800;
 | |
|   color: var(--ink);
 | |
|   transition: all 0.15s ease;
 | |
| }
 | |
| .cell.firstday h1.day-number {
 | |
|   font-weight: 400;
 | |
| }
 | |
| .cell.weekend h1.day-number {
 | |
|   color: var(--weekend);
 | |
| }
 | |
| .cell.firstday h1.day-number {
 | |
|   color: var(--firstday);
 | |
| }
 | |
| .cell.today::before {
 | |
|   content: '';
 | |
|   position: absolute;
 | |
|   top: 50%;
 | |
|   left: 50%;
 | |
|   transform: translate(-50%, -50%);
 | |
|   width: calc(100% + .2rem);
 | |
|   height: calc(100% + .2rem);
 | |
|   border-radius: 1rem;
 | |
|   background: transparent;
 | |
|   border: 0.3em solid var(--today);
 | |
|   z-index: 15;
 | |
|   pointer-events: none;
 | |
| }
 | |
| 
 | |
| /* Search highlight animation */
 | |
| .cell.search-highlight-flash::after {
 | |
|   content: '';
 | |
|   position: absolute;
 | |
|   top: 50%;
 | |
|   left: 50%;
 | |
|   transform: translate(-50%, -50%);
 | |
|   width: calc(100% + .2rem);
 | |
|   height: calc(100% + .2rem);
 | |
|   border-radius: 1rem;
 | |
|   background: transparent;
 | |
|   border: 0.3em solid var(--strong);
 | |
|   z-index: 16;
 | |
|   pointer-events: none;
 | |
| }
 | |
| 
 | |
| .cell.search-highlight-flash::after { animation: search-highlight-flash 1.5s ease-out forwards; }
 | |
| 
 | |
| @keyframes search-highlight-flash {0%{opacity:0;transform:translate(-50%,-50%) scale(.8);border-width:.1em}15%{opacity:1;transform:translate(-50%,-50%) scale(1.05);border-width:.4em}30%{opacity:1;transform:translate(-50%,-50%) scale(1);border-width:.3em}100%{opacity:0;transform:translate(-50%,-50%) scale(1);border-width:.3em}}
 | |
| .cell.selected h1.day-number {
 | |
|   opacity: 0.3;
 | |
|   filter: brightness(1.2);
 | |
| }
 | |
| .cell {
 | |
|   background-image: linear-gradient(
 | |
|     135deg,
 | |
|     var(--holiday-grad-start, rgba(255, 255, 255, 0.3)) 0%,
 | |
|     var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
 | |
|   );
 | |
| }
 | |
| @media (prefers-color-scheme: dark) {
 | |
|   .cell {
 | |
|     background-image: linear-gradient(
 | |
|       135deg,
 | |
|       var(--holiday-grad-start, rgba(255, 255, 255, 0.05)) 0%,
 | |
|       var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
 | |
|     );
 | |
|   }
 | |
| }
 | |
| .lunar-phase {
 | |
|   grid-area: lunar-phase;
 | |
|   position: absolute;
 | |
|   inset-block-start: 0.5em;
 | |
|   inset-inline-end: 0.2em;
 | |
|   font-size: 0.8em;
 | |
|   opacity: 0.7;
 | |
| }
 | |
| 
 | |
| .compact-date {
 | |
|   position: absolute;
 | |
|   top: 0.25em;
 | |
|   left: 0.25em;
 | |
|   inset-inline-end: 1rem; /* Space for lunar phase */
 | |
|   font-weight: 400;
 | |
|   color: var(--ink);
 | |
|   line-height: 1;
 | |
|   pointer-events: none;
 | |
|   white-space: pre-wrap;
 | |
| }
 | |
| 
 | |
| .cell.weekend .compact-date {
 | |
|   color: var(--weekend);
 | |
| }
 | |
| .cell.firstday .compact-date {
 | |
|   color: var(--firstday);
 | |
| }
 | |
| .cell.today .compact-date {
 | |
|   color: var(--strong);
 | |
| }
 | |
| .cell.selected .compact-date {
 | |
|   color: var(--strong);
 | |
| }
 | |
| 
 | |
| .holiday-info {
 | |
|   grid-area: holiday-info;
 | |
|   align-self: end;
 | |
|   overflow: hidden;
 | |
|   max-width: 100%;
 | |
|   color: var(--holiday);
 | |
|   font-size: 1em;
 | |
|   font-weight: 400;
 | |
|   line-height: 1.0;
 | |
|   padding-inline: 0.15em;
 | |
|   padding-block: 0;
 | |
|   pointer-events: auto;
 | |
| }
 | |
| </style>
 | 
