2025-06-14 17:27:06 +01:00
|
|
|
<template>
|
|
|
|
<div class="page" :class="libraryItemIdStreaming ? 'streaming' : ''">
|
|
|
|
<app-book-shelf-toolbar page="calendar" />
|
|
|
|
|
|
|
|
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
|
|
|
<div class="w-full max-w-6xl mx-auto py-4">
|
|
|
|
<!-- Mobile Layout (stacked) -->
|
|
|
|
<div class="block md:hidden mb-4 px-2 relative z-50">
|
|
|
|
<div class="text-center mb-3">
|
|
|
|
<p class="text-xl font-semibold">{{ $strings.HeaderCalendar }}</p>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="flex items-center justify-center space-x-2 mb-3">
|
|
|
|
<ui-btn small @click="navigateMonth(-1)" :disabled="processing" class="h-9 px-2">
|
|
|
|
<span class="material-symbols text-lg">chevron_left</span>
|
|
|
|
</ui-btn>
|
|
|
|
|
|
|
|
<div class="flex items-center space-x-2 relative z-50">
|
|
|
|
<ui-dropdown v-model="selectedMonth" :items="monthOptions" small class="w-28" @input="onMonthYearChange" :disabled="processing" />
|
|
|
|
<ui-dropdown v-model="selectedYear" :items="yearOptions" small class="w-20" @input="onMonthYearChange" :disabled="processing" />
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<ui-btn small @click="navigateMonth(1)" :disabled="processing" class="h-9 px-2">
|
|
|
|
<span class="material-symbols text-lg">chevron_right</span>
|
|
|
|
</ui-btn>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="flex justify-center">
|
|
|
|
<ui-btn small @click="goToToday" :disabled="processing || isCurrentMonth" class="h-9">
|
|
|
|
{{ $strings.ButtonToday }}
|
|
|
|
</ui-btn>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Desktop Layout (single row) -->
|
|
|
|
<div class="hidden md:flex items-center justify-between mb-4 px-4 md:px-0 relative z-50">
|
|
|
|
<p class="text-xl font-semibold">{{ $strings.HeaderCalendar }}</p>
|
|
|
|
|
|
|
|
<div class="flex items-center space-x-2">
|
|
|
|
<ui-btn small @click="navigateMonth(-1)" :disabled="processing" class="h-9 px-2">
|
|
|
|
<span class="material-symbols text-lg">chevron_left</span>
|
|
|
|
</ui-btn>
|
|
|
|
|
|
|
|
<div class="flex items-center space-x-2 min-w-64 relative z-50">
|
|
|
|
<ui-dropdown v-model="selectedMonth" :items="monthOptions" small class="w-32" @input="onMonthYearChange" :disabled="processing" />
|
|
|
|
<ui-dropdown v-model="selectedYear" :items="yearOptions" small class="w-20" @input="onMonthYearChange" :disabled="processing" />
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<ui-btn small @click="navigateMonth(1)" :disabled="processing" class="h-9 px-2">
|
|
|
|
<span class="material-symbols text-lg">chevron_right</span>
|
|
|
|
</ui-btn>
|
|
|
|
|
|
|
|
<ui-btn small @click="goToToday" :disabled="processing || isCurrentMonth" class="ml-2 h-9">
|
|
|
|
{{ $strings.ButtonToday }}
|
|
|
|
</ui-btn>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div v-if="processing" class="flex justify-center py-8">
|
|
|
|
<ui-loading-indicator />
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div v-else class="bg-primary/25 rounded-lg overflow-hidden">
|
|
|
|
<div class="grid grid-cols-7 bg-primary/40">
|
|
|
|
<div v-for="day in weekDays" :key="day" class="p-2 sm:p-4 text-center font-semibold text-gray-200 border-r border-black/20 last:border-r-0">
|
|
|
|
<span class="text-sm sm:text-base">{{ day }}</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="grid grid-cols-7">
|
|
|
|
<div
|
|
|
|
v-for="day in calendarDays"
|
|
|
|
:key="`${day.date}-${day.isCurrentMonth}`"
|
|
|
|
class="min-h-24 sm:min-h-32 border-r border-b border-black/20 last:border-r-0 relative"
|
|
|
|
:class="{
|
|
|
|
'bg-black/20': !day.isCurrentMonth,
|
|
|
|
'bg-blue-600/30': day.isCurrentMonth && day.isToday,
|
|
|
|
'bg-transparent': day.isCurrentMonth && !day.isToday
|
|
|
|
}"
|
|
|
|
>
|
|
|
|
<div v-if="day.isToday" class="absolute inset-0 border-2 border-blue-500 pointer-events-none" style="z-index: 5"></div>
|
|
|
|
<div v-if="day.isToday" class="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full" style="z-index: 6"></div>
|
|
|
|
<div class="p-1 sm:p-2 relative" style="z-index: 1">
|
|
|
|
<span
|
|
|
|
class="text-xs sm:text-sm font-medium"
|
|
|
|
:class="{
|
|
|
|
'text-gray-500': !day.isCurrentMonth,
|
|
|
|
'text-blue-200 font-semibold': day.isCurrentMonth && day.isToday,
|
|
|
|
'text-gray-200': day.isCurrentMonth && !day.isToday
|
|
|
|
}"
|
|
|
|
>
|
|
|
|
{{ day.dayNumber }}
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div class="px-1 sm:px-2 pb-1 sm:pb-2 space-y-1 relative" style="z-index: 1">
|
2025-06-14 18:16:36 +01:00
|
|
|
<div v-for="episode in day.episodes" :key="episode.id" @click="clickEpisode(episode)" class="p-1 rounded text-xs cursor-pointer hover:bg-white/10 transition-colors" :class="getItemColorClass(episode)">
|
2025-06-14 17:27:06 +01:00
|
|
|
<div class="font-medium truncate text-xs">
|
|
|
|
{{ episode.podcastTitle }}
|
|
|
|
</div>
|
|
|
|
<div class="text-gray-300 truncate text-xs">
|
|
|
|
<span v-if="episode.season || episode.episode" class="mr-1">
|
|
|
|
<span v-if="episode.season">S{{ episode.season }}</span
|
|
|
|
><span v-if="episode.episode">E{{ episode.episode }}</span>
|
|
|
|
</span>
|
|
|
|
{{ episode.title }}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script>
|
|
|
|
export default {
|
|
|
|
async asyncData({ params, redirect, store }) {
|
|
|
|
var libraryId = params.library
|
|
|
|
var libraryData = await store.dispatch('libraries/fetch', libraryId)
|
|
|
|
if (!libraryData) {
|
|
|
|
return redirect('/oops?message=Library not found')
|
|
|
|
}
|
|
|
|
|
|
|
|
const library = libraryData.library
|
|
|
|
if (library.mediaType === 'book') {
|
|
|
|
return redirect(`/library/${libraryId}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
libraryId
|
|
|
|
}
|
|
|
|
},
|
|
|
|
data() {
|
|
|
|
return {
|
|
|
|
currentDate: new Date(),
|
|
|
|
episodes: [],
|
|
|
|
processing: false,
|
|
|
|
openingItem: false,
|
|
|
|
selectedMonth: new Date().getMonth(),
|
|
|
|
selectedYear: new Date().getFullYear(),
|
|
|
|
itemColors: [
|
|
|
|
'bg-blue-600/80',
|
|
|
|
'bg-sky-600/80',
|
|
|
|
'bg-cyan-600/80',
|
|
|
|
'bg-indigo-600/80',
|
|
|
|
'bg-green-600/80',
|
|
|
|
'bg-emerald-600/80',
|
|
|
|
'bg-teal-600/80',
|
|
|
|
'bg-lime-600/80',
|
|
|
|
'bg-purple-600/80',
|
|
|
|
'bg-violet-600/80',
|
|
|
|
'bg-fuchsia-600/80',
|
|
|
|
'bg-pink-600/80',
|
|
|
|
'bg-red-600/80',
|
|
|
|
'bg-rose-600/80',
|
|
|
|
'bg-orange-600/80',
|
|
|
|
'bg-amber-600/80',
|
|
|
|
'bg-yellow-600/80',
|
|
|
|
'bg-yellow-500/80',
|
|
|
|
'bg-slate-600/80',
|
|
|
|
'bg-gray-600/80',
|
|
|
|
'bg-zinc-600/80',
|
|
|
|
'bg-stone-600/80',
|
|
|
|
'bg-blue-500/80',
|
|
|
|
'bg-green-500/80',
|
|
|
|
'bg-purple-500/80',
|
|
|
|
'bg-red-500/80',
|
|
|
|
'bg-orange-500/80',
|
|
|
|
'bg-pink-500/80',
|
|
|
|
'bg-indigo-500/80',
|
|
|
|
'bg-teal-500/80'
|
|
|
|
]
|
|
|
|
}
|
|
|
|
},
|
|
|
|
computed: {
|
|
|
|
libraryItemIdStreaming() {
|
|
|
|
return this.$store.getters['getLibraryItemIdStreaming']
|
|
|
|
},
|
|
|
|
firstDayOfWeek() {
|
|
|
|
return this.$store.state.serverSettings?.calendarFirstDayOfWeek || 0
|
|
|
|
},
|
|
|
|
monthOptions() {
|
|
|
|
const monthNames = [
|
|
|
|
this.$strings.LabelCalendarJanuary,
|
|
|
|
this.$strings.LabelCalendarFebruary,
|
|
|
|
this.$strings.LabelCalendarMarch,
|
|
|
|
this.$strings.LabelCalendarApril,
|
|
|
|
this.$strings.LabelCalendarMay,
|
|
|
|
this.$strings.LabelCalendarJune,
|
|
|
|
this.$strings.LabelCalendarJuly,
|
|
|
|
this.$strings.LabelCalendarAugust,
|
|
|
|
this.$strings.LabelCalendarSeptember,
|
|
|
|
this.$strings.LabelCalendarOctober,
|
|
|
|
this.$strings.LabelCalendarNovember,
|
|
|
|
this.$strings.LabelCalendarDecember
|
|
|
|
]
|
|
|
|
|
|
|
|
return monthNames.map((name, index) => ({
|
|
|
|
text: name,
|
|
|
|
value: index
|
|
|
|
}))
|
|
|
|
},
|
|
|
|
yearOptions() {
|
|
|
|
const currentYear = new Date().getFullYear()
|
|
|
|
const startYear = currentYear - 10
|
|
|
|
const endYear = currentYear + 2
|
|
|
|
|
|
|
|
const years = []
|
|
|
|
for (let year = endYear; year >= startYear; year--) {
|
|
|
|
years.push({
|
|
|
|
text: year.toString(),
|
|
|
|
value: year
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return years
|
|
|
|
},
|
|
|
|
weekDays() {
|
|
|
|
const allDays = [this.$strings.LabelCalendarSundayShort, this.$strings.LabelCalendarMondayShort, this.$strings.LabelCalendarTuesdayShort, this.$strings.LabelCalendarWednesdayShort, this.$strings.LabelCalendarThursdayShort, this.$strings.LabelCalendarFridayShort, this.$strings.LabelCalendarSaturdayShort]
|
|
|
|
|
|
|
|
if (this.firstDayOfWeek === 1) {
|
|
|
|
return [...allDays.slice(1), allDays[0]]
|
|
|
|
}
|
|
|
|
return allDays
|
|
|
|
},
|
|
|
|
isCurrentMonth() {
|
|
|
|
const today = new Date()
|
|
|
|
return this.currentDate.getFullYear() === today.getFullYear() && this.currentDate.getMonth() === today.getMonth()
|
|
|
|
},
|
|
|
|
calendarDays() {
|
|
|
|
const year = this.currentDate.getFullYear()
|
|
|
|
const month = this.currentDate.getMonth()
|
|
|
|
|
|
|
|
const firstDay = new Date(year, month, 1)
|
|
|
|
const firstCalendarDay = new Date(firstDay)
|
|
|
|
|
|
|
|
let daysToSubtract = firstDay.getDay() - this.firstDayOfWeek
|
|
|
|
if (daysToSubtract < 0) {
|
|
|
|
daysToSubtract += 7
|
|
|
|
}
|
|
|
|
firstCalendarDay.setDate(firstCalendarDay.getDate() - daysToSubtract)
|
|
|
|
|
|
|
|
const days = []
|
|
|
|
const today = new Date()
|
|
|
|
const currentDay = new Date(firstCalendarDay)
|
|
|
|
|
|
|
|
for (let i = 0; i < 42; i++) {
|
|
|
|
const dayKey = this.formatDateKey(currentDay)
|
|
|
|
const dayEpisodes = this.episodes.filter((episode) => {
|
|
|
|
if (!episode.publishedAt) return false
|
|
|
|
const episodeDate = new Date(episode.publishedAt)
|
|
|
|
return this.formatDateKey(episodeDate) === dayKey
|
|
|
|
})
|
|
|
|
|
|
|
|
days.push({
|
|
|
|
date: new Date(currentDay),
|
|
|
|
dayNumber: currentDay.getDate(),
|
|
|
|
isCurrentMonth: currentDay.getMonth() === month,
|
|
|
|
isToday: this.isSameDay(currentDay, today),
|
|
|
|
episodes: dayEpisodes
|
|
|
|
})
|
|
|
|
|
|
|
|
currentDay.setDate(currentDay.getDate() + 1)
|
|
|
|
}
|
|
|
|
|
|
|
|
return days
|
|
|
|
}
|
|
|
|
},
|
|
|
|
watch: {
|
|
|
|
currentDate: {
|
|
|
|
handler(newDate) {
|
|
|
|
this.selectedMonth = newDate.getMonth()
|
|
|
|
this.selectedYear = newDate.getFullYear()
|
|
|
|
},
|
|
|
|
immediate: true
|
|
|
|
}
|
|
|
|
},
|
|
|
|
methods: {
|
|
|
|
async navigateMonth(direction) {
|
|
|
|
const newDate = new Date(this.currentDate)
|
|
|
|
newDate.setMonth(newDate.getMonth() + direction)
|
|
|
|
this.currentDate = newDate
|
|
|
|
await this.loadMonthEpisodes()
|
|
|
|
},
|
|
|
|
async goToToday() {
|
|
|
|
if (this.isCurrentMonth) return
|
|
|
|
this.currentDate = new Date()
|
|
|
|
await this.loadMonthEpisodes()
|
|
|
|
},
|
|
|
|
async onMonthYearChange() {
|
|
|
|
const newDate = new Date(this.selectedYear, this.selectedMonth, 1)
|
|
|
|
this.currentDate = newDate
|
|
|
|
await this.loadMonthEpisodes()
|
|
|
|
},
|
|
|
|
async loadMonthEpisodes() {
|
|
|
|
this.processing = true
|
|
|
|
|
|
|
|
const year = this.currentDate.getFullYear()
|
|
|
|
const month = this.currentDate.getMonth()
|
|
|
|
const startOfMonth = new Date(year, month, 1)
|
|
|
|
const endOfMonth = new Date(year, month + 1, 0)
|
|
|
|
|
|
|
|
const startDate = new Date(startOfMonth)
|
|
|
|
startDate.setDate(startDate.getDate() - 7)
|
|
|
|
const endDate = new Date(endOfMonth)
|
|
|
|
endDate.setDate(endDate.getDate() + 7)
|
|
|
|
|
|
|
|
try {
|
|
|
|
const episodePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episodes-calendar`, {
|
|
|
|
params: {
|
|
|
|
start: startDate.toISOString(),
|
|
|
|
end: endDate.toISOString()
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
this.episodes = episodePayload.episodes || []
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to get calendar episodes', error)
|
|
|
|
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
|
|
|
this.episodes = []
|
|
|
|
} finally {
|
|
|
|
this.processing = false
|
|
|
|
}
|
|
|
|
},
|
|
|
|
async clickEpisode(episode) {
|
|
|
|
if (this.openingItem) return
|
|
|
|
this.openingItem = true
|
|
|
|
|
|
|
|
try {
|
|
|
|
const fullLibraryItem = await this.$axios.$get(`/api/items/${episode.libraryItemId}`)
|
|
|
|
this.$store.commit('setSelectedLibraryItem', fullLibraryItem)
|
|
|
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
|
|
|
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Failed to get library item', error)
|
|
|
|
this.$toast.error('Failed to get library item')
|
|
|
|
} finally {
|
|
|
|
this.openingItem = false
|
|
|
|
}
|
|
|
|
},
|
|
|
|
formatDateKey(date) {
|
|
|
|
return date.toISOString().split('T')[0]
|
|
|
|
},
|
|
|
|
isSameDay(date1, date2) {
|
|
|
|
return this.formatDateKey(date1) === this.formatDateKey(date2)
|
|
|
|
},
|
2025-06-14 18:16:36 +01:00
|
|
|
getItemColorClass(episode) {
|
2025-06-14 17:27:06 +01:00
|
|
|
const podcastId = episode.podcastId || episode.libraryItemId
|
2025-06-14 18:16:36 +01:00
|
|
|
const hash = this.itemGroupingHash(podcastId)
|
2025-06-14 17:27:06 +01:00
|
|
|
return this.itemColors[hash % this.itemColors.length]
|
|
|
|
},
|
2025-06-14 18:16:36 +01:00
|
|
|
itemGroupingHash(str) {
|
2025-06-14 17:27:06 +01:00
|
|
|
let hash = 0
|
|
|
|
if (!str) return hash
|
|
|
|
|
|
|
|
const stringToHash = str.toString()
|
|
|
|
for (let i = 0; i < stringToHash.length; i++) {
|
|
|
|
const char = stringToHash.charCodeAt(i)
|
|
|
|
hash = (hash << 5) - hash + char
|
|
|
|
hash = hash & hash
|
|
|
|
}
|
|
|
|
return Math.abs(hash)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
mounted() {
|
|
|
|
this.loadMonthEpisodes()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
.calendar-grid {
|
|
|
|
display: grid;
|
|
|
|
grid-template-columns: repeat(7, 1fr);
|
|
|
|
}
|
|
|
|
</style>
|