Last active
November 15, 2024 16:10
-
-
Save StoneyEagle/cdf944de9f8699b01638f11f71432e85 to your computer and use it in GitHub Desktop.
Configuration-Driven UI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ApiController] | |
[Tags("Media")] | |
[ApiVersion(1.0)] | |
[Authorize] | |
[Route("api/v{version:apiVersion}")] | |
public class HomeController : BaseController | |
{ | |
[HttpGet] | |
[Route("home")] | |
public async Task<IActionResult> Home() | |
{ | |
IEnumerable<UserData> filteredContinueWatching = []; | |
List<GenreRowDto<GenreRowItemDto>> genres = []; | |
return Ok(new Render | |
{ | |
Data = [ | |
new ComponentDto<GenreRowItemDto> | |
{ | |
Component = "NMHomeCard", | |
Update = | |
{ | |
When = "pageLoad", | |
Link = new Uri("/home/card", UriKind.Relative), | |
}, | |
Props = | |
{ | |
Data = genres.Randomize().FirstOrDefault() | |
?.Items.Randomize().FirstOrDefault() ?? new GenreRowItemDto(), | |
} | |
}, | |
new ComponentDto<ContinueWatchingItemDto> | |
{ | |
Component = "NMCarousel", | |
Update = | |
{ | |
When = "pageLoad", | |
Link = new Uri("/home/continue", UriKind.Relative), | |
}, | |
Props = | |
{ | |
Title = "Continue watching".Localize(), | |
MoreLink = null, | |
Items = continueWatching | |
.Select(item => new ComponentDto<ContinueWatchingItemDto> | |
{ | |
Component = "NMCard", | |
Props = | |
{ | |
Data = new ContinueWatchingItemDto(item, country), | |
Watch = true, | |
ContextMenuItems = | |
[ | |
new Dictionary<string, object> | |
{ | |
{"label", "Remove from watchlist".Localize()}, | |
{"icon", "mooooom-trash"}, | |
{"method", "DELETE"}, | |
{"confirm", "Are you sure you want to remove this from continue watching?".Localize()}, | |
{"args", new Dictionary<string, object> | |
{ | |
{"url", new Uri($"/userdata/continue", UriKind.Relative)}, | |
}} | |
} | |
] | |
} | |
}) | |
} | |
}, | |
..genres.Select(genre => new ComponentDto<GenreRowItemDto> | |
{ | |
Component = "NMCarousel", | |
Props = | |
{ | |
Title = genre.Title, | |
MoreLink = genre.MoreLink, | |
Items = genre.Items.Select(item => new ComponentDto<GenreRowItemDto> | |
{ | |
Component = "NMCard", | |
Props = | |
{ | |
Data = item ?? new GenreRowItemDto(), | |
Watch = true, | |
} | |
}) | |
} | |
}) | |
] | |
}); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script setup lang="ts"> | |
import {computed, type PropType} from 'vue'; | |
import type {LibraryResponse} from '@/types/api/base/library'; | |
import {pickPaletteColor} from '@/lib/colorHelper'; | |
import {showBackdrops} from '@/store/preferences'; | |
import { | |
type ContextMenu, | |
contextMenu, | |
makeContextMenu, | |
setContextMenu, | |
setContextMenuContext | |
} from '@/store/contextMenuItems'; | |
import TMDBImage from '@/components/Images/TMDBImage.vue'; | |
import CardIndicator from '@/components/Cards/CardIndicator.vue'; | |
const props = defineProps({ | |
data: { | |
type: Object as PropType<LibraryResponse> | undefined, | |
required: true, | |
}, | |
watch: { | |
type: Boolean, | |
required: false, | |
default: false, | |
}, | |
context_menu_items: { | |
type: Array as PropType<ContextMenu[]>, | |
required: false, | |
default: () => [], | |
} | |
}); | |
const scrollLetter = computed(() => `scroll_${props.data.titleSort?.[0]?.toUpperCase?.()}`); | |
const image = computed(() => { | |
return props.data[showBackdrops.value ? 'backdrop' : 'poster'] as string; | |
}); | |
const onRightClick = (event: Event) => { | |
if(props.context_menu_items) { | |
setContextMenu(makeContextMenu(props.context_menu_items)); | |
setContextMenuContext(props.data); | |
contextMenu.value.show(event); | |
} | |
}; | |
</script> | |
<template> | |
<RouterLink | |
v-if="data" | |
:data-scroll="scrollLetter" | |
@contextmenu="onRightClick($event)" | |
:to="data.link" | |
class="group/card flex flex-col h-full items-center focus-outline overflow-clip relative rounded-lg select-none shadow-[0px_0px_0_1px_rgb(var(--color-focus,var(--color-theme-6))/70%)] w-full z-0 bg-auto-50/70 flex-grow-0" | |
:class="showBackdrops ? 'aspect-backdrop' : 'aspect-poster'" | |
:style="` | |
--color-focus: ${data.color_palette?.[showBackdrops ? 'backdrop' : 'poster'] | |
? pickPaletteColor(data.color_palette?.[showBackdrops ? 'backdrop' : 'poster']) | |
.replace(/,/gu, ' ') | |
.replace(')', '') | |
.replace('rgb(', '') | |
: ''}; | |
`"> | |
<div class="backdropCard-overlay"></div> | |
<TMDBImage | |
:path="image" | |
:title="data.title" | |
loading="lazy" | |
:size="showBackdrops ? 330 : 180" | |
:aspect="showBackdrops ? 'backdrop' : 'poster'" | |
:colorPalette="data.color_palette?.[showBackdrops ? 'backdrop' : 'poster']" | |
className="h-full overflow-clip rounded-lg"/> | |
<template v-if="showBackdrops"> | |
<div v-if="!!data.logo" | |
class="absolute inset-0 h-full w-full" | |
> | |
<div | |
class="pointer-events-none absolute inset-0 z-0 mt-auto h-4/5 bg-gradient-to-t from-auto-1 via-auto-1/60"></div> | |
<div | |
class="absolute bottom-0 left-0 h-full max-h-24 w-full max-w-[66%]" | |
> | |
<TMDBImage | |
:path="data.logo" | |
:title="data.title" | |
:colorPalette="data.color_palette?.logo" | |
:size="500" | |
loading="lazy" | |
class="w-auto object-contain h-available object-[0_0%] max-h-inherit !duration-700 children:!duration-700" | |
className="mr-auto p-4 !duration-700 children:!duration-700" | |
type="logo"/> | |
</div> | |
</div> | |
<div v-else class="absolute inset-0 h-full w-full"> | |
<div | |
class="pointer-events-none absolute inset-0 z-0 mt-auto h-4/5 bg-gradient-to-t from-auto-1 via-auto-1/60"></div> | |
<div | |
class="absolute bottom-4 left-4 w-full max-w-[66%]" | |
> | |
<p class="z-10 w-auto text-xl font-bold line-clamp-2 leading-[1.2] text-auto-12 empty:hidden dark:font-medium"> | |
{{ data.title }} | |
</p> | |
</div> | |
</div> | |
</template> | |
<div v-else | |
:class="`flex flex-col justify-start items-start w-full h-12 z-0 absolute left-0 transition-all duration-300 px-2 py-1 group-hover/card:-bottom-0 ${image ? '-bottom-20' : 'bottom-0'}`"> | |
<div | |
class="absolute inset-0 z-0 opacity-0 group-hover/card:opacity-100 transition-all duration-300 bg-auto-1/60"></div> | |
<p class="z-10 w-auto flex-shrink-0 flex-grow-0 self-stretch text-xs font-semibold line-clamp-2 leading-[1.2] text-auto-12 empty:hidden dark:font-medium"> | |
{{ data.title }} | |
</p> | |
</div> | |
<CardIndicator :data="data"/> | |
</RouterLink> | |
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script setup lang="ts"> | |
import {onBeforeMount, onMounted, PropType, ref} from 'vue'; | |
import {useTranslation} from 'i18next-vue'; | |
import {Swiper} from 'swiper'; | |
import {register, SwiperContainer} from 'swiper/element/bundle'; | |
import {Swiper as SwiperComponent, SwiperSlide} from 'swiper/vue'; | |
import type {Component} from '@/types/config'; | |
import {isMobile} from '@/config/global'; | |
import {mappedEntries} from '@/lib/stringArray'; | |
import {Breakpoints, breakpoints, swiperConfig} from '@/lib/swiper-config'; | |
import MoooomIcon from '@/components/Images/icons/MoooomIcon.vue'; | |
const {t} = useTranslation(); | |
const props = defineProps({ | |
title: { | |
type: String, | |
required: true, | |
}, | |
more_link: { | |
type: String, | |
required: false, | |
}, | |
more_link_text: { | |
type: String, | |
required: false, | |
default: 'See more', | |
}, | |
items: { | |
type: Array as PropType<Array<Component>>, | |
required: false, | |
default: [], | |
}, | |
type: { | |
type: String as PropType<'poster' | 'backdrop'>, | |
required: false, | |
}, | |
limitCardCountBy: { | |
type: Number, | |
required: false, | |
}, | |
backdropCards: { | |
type: Boolean, | |
required: false, | |
default: false, | |
}, | |
index : { | |
type: Number, | |
required: true, | |
}, | |
}); | |
const backButtonEnabled = ref(false); | |
const nextButtonEnabled = ref(true); | |
const isLastSlide = ref(false); | |
const hasScroll = ref(false); | |
const swiperElement = ref<HTMLDivElement>(); | |
const onProgress = (swiper: Swiper, progress: number) => { | |
swiper.progress = Math.floor(((progress * 100) + 1) / 100); | |
backButtonEnabled.value = !swiper.isBeginning; | |
nextButtonEnabled.value = swiper.progress < 1; | |
isLastSlide.value = swiper.progress === 1; | |
hasScroll.value = !swiper.isLocked; | |
}; | |
const onSlideChange = (swiper: Swiper) => { | |
swiper.progress = Math.floor(((swiper.progress * 100) + 1) / 100); | |
backButtonEnabled.value = !swiper.isBeginning; | |
nextButtonEnabled.value = swiper.progress < 1; | |
isLastSlide.value = swiper.progress === 1; | |
hasScroll.value = !swiper.isLocked; | |
}; | |
register(); | |
const reset = () => { | |
const swiper: Swiper = document.querySelector<SwiperContainer>(`.swiper-${props.title?.replace(/[\s&#]/gu, '-')}`)?.swiper as Swiper; | |
swiper?.slideTo(0, 300); | |
hasScroll.value = true; | |
}; | |
const next = () => { | |
const swiper: Swiper = document.querySelector<SwiperContainer>(`.swiper-${props.title?.replace(/[\s&#]/gu, '-')}`)?.swiper as Swiper; | |
swiper?.slideNext(300); | |
}; | |
const prev = () => { | |
const swiper: Swiper = document.querySelector<SwiperContainer>(`.swiper-${props.title?.replace(/[\s&#]/gu, '-')}`)?.swiper as Swiper; | |
swiper?.slidePrev(300); | |
}; | |
const offsetBefore = window.innerWidth < 800 | |
? 10 | |
: 20; | |
const bp = ref<Breakpoints>(); | |
onBeforeMount(() => { | |
if (!props.limitCardCountBy) { | |
bp.value = breakpoints(props.backdropCards); | |
return; | |
} | |
const newBp: Breakpoints = <Breakpoints>{}; | |
for (const [key, value] of mappedEntries(breakpoints(props.backdropCards))) { | |
newBp[key] = { | |
...value, | |
slidesPerView: value.slidesPerView - props.limitCardCountBy, | |
slidesPerGroup: value.slidesPerGroup - props.limitCardCountBy, | |
}} | |
bp.value = newBp; | |
}) | |
const visible = ref(false); | |
onMounted(() => { | |
setTimeout(() => { | |
visible.value = true; | |
}, 50 * props.index); | |
}); | |
</script> | |
<template> | |
<div v-if="visible && items?.length" | |
class="mt-4 mb-2 flex w-auto flex-shrink-0 flex-grow-0 flex-col items-start justify-start gap-2 self-stretch will-change-auto" | |
> | |
<div class="flex w-full flex-1 flex-col gap-2"> | |
<div class="relative ml-2 flex flex-shrink-0 flex-grow-0 items-center self-stretch"> | |
<h3 v-if="title" class="text-2xl font-bold text-auto-12 mr-2 ml-1 sm:ml-3 text-slate-light-1"> | |
{{ title }} | |
</h3> | |
<slot v-else name="selector"></slot> | |
<RouterLink v-if="more_link" | |
:to="more_link" | |
class="text-base text-slate-dark-9 dark:text-slate-light-9 flex items-center ml-auto sm:ml-4 mr-2 sm:mr-auto"> | |
<span>{{ more_link_text }}</span> | |
<MoooomIcon icon="chevronRight" className="w-6 mt-1"/> | |
</RouterLink> | |
<div class="flex flex-shrink-0 flex-grow-0 items-start justify-start gap-2 pr-4 ml-auto" v-if="!isMobile"> | |
<button | |
:class="`hidden sm:flex justify-center items-center p-1 rounded-lg bg-auto-alpha-7 active:scale-95 hover:bg-auto-alpha-9 transition-transform duration-300 ${backButtonEnabled ? '' : 'cursor-not-allowed opacity-50'}`" | |
:onclick="prev" v-if="hasScroll" | |
> | |
<MoooomIcon class="w-6" icon="chevronLeft"/> | |
</button> | |
<button | |
:class="`hidden sm:flex justify-center items-center p-1 rounded-lg bg-auto-alpha-7 active:scale-95 hover:bg-auto-alpha-9 transition-transform duration-300 ${hasScroll ? '' : 'cursor-not-allowed opacity-50'}`" | |
:onclick="isLastSlide ? reset : next" v-if="hasScroll" | |
> | |
<MoooomIcon :class="`w-6 ${!nextButtonEnabled && isLastSlide ? 'opacity-0' : ''}`" | |
icon="chevronRight" v-if="hasScroll && !isLastSlide" | |
/> | |
<MoooomIcon :class="`w-6 ${ isLastSlide ? '' : 'opacity-0'}`" icon="chevronLeftDouble" | |
v-if="hasScroll && isLastSlide" | |
/> | |
</button> | |
</div> | |
</div> | |
<div class="gap-3 py-1 pr-0 w-available swiper"> | |
<SwiperComponent | |
v-bind="swiperConfig(backdropCards) as any" | |
ref="swiperElement" | |
:class="`swiper-${title?.replace(/[\s&#]/gu, '-')}`" | |
:slidesOffsetBefore="offsetBefore" | |
:breakpoints="bp" | |
data-spatial-container="row" | |
@progress="onProgress" | |
@slideChange="onSlideChange"> | |
<template v-for="(item, itemIndex) in items" | |
:key="item?.id"> | |
<swiper-slide v-if="item?.id" class="flex"> | |
<component | |
:key="item.id" | |
:is="item.component" | |
v-bind="item.props" | |
/> | |
</swiper-slide> | |
</template> | |
</SwiperComponent> | |
</div> | |
</div> | |
</div> | |
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script setup lang="ts"> | |
import {type PropType, toRaw} from 'vue'; | |
import {useRoute} from 'vue-router'; | |
import {useIsMutating, useMutation, useQuery} from '@tanstack/vue-query'; | |
import type {HomeItem} from '@/types/api/base/home'; | |
import serverClient from '@/lib/clients/serverClient'; | |
import router from '@/router'; | |
import {setTitle} from '@/lib/stringArray'; | |
import {setBackground, setColorPalette} from '@/store/ui'; | |
interface Component<T> { | |
id: string; | |
component: string; | |
props: Props<T>; | |
update: { | |
when: string; | |
link: string; | |
body: { | |
replace_id: string; | |
[key: string]: string; | |
} | |
}; | |
} | |
interface Props<T> { | |
id: null; | |
title: string; | |
moreLink: null; | |
children: T[]; | |
} | |
const props = defineProps({ | |
options: { | |
type: Object as PropType<Component<HomeItem>[] & { queryKey: string[], path?: string }>, | |
required: false, | |
default: () => ({ | |
keepForever: true, | |
}), | |
}, | |
}); | |
const routeName = router.currentRoute.value.name; | |
const isMutating = useIsMutating({ | |
mutationKey: props.options.queryKey, | |
}); | |
/** | |
* Returns the queryKey for the query based on the url of the page. | |
*/ | |
const queryKey = (): string[] => { | |
const route = useRoute(); | |
const queryKey: string[] = []; | |
(props.options?.path ?? route.path) | |
?.split('/') | |
.slice(1) | |
.forEach(p => { | |
if (p.includes('?')) { | |
queryKey.push(p.split('?')[0]); | |
queryKey.push(p.split('?')[1].split('=')[1]); | |
} else { | |
queryKey.push(p); | |
} | |
}); | |
return queryKey; | |
}; | |
// eslint-disable-next-line vue/no-mutating-props | |
props.options.queryKey = props.options.queryKey ?? queryKey(); | |
const {data: homeData} = useQuery<Component<HomeItem>[]>({ | |
...props.options, | |
queryFn: () => serverClient() | |
.get<{ data: Component<HomeItem>[] }>(props.options.path ?? router.currentRoute.value.path) | |
.then(({data}) => { | |
return data.data; | |
}), | |
}); | |
const {data: mutatedData, mutate} = useMutation({ | |
mutationKey: props.options.queryKey, | |
mutationFn: async (mutations: Component<HomeItem>[]) => { | |
const data = [...homeData.value?.map(d => { | |
return structuredClone(toRaw(d)); | |
}) ?? []]; | |
for (const item of mutations) { | |
await serverClient() | |
.post<{ data: Component<HomeItem>[] }>(item.update.link, { | |
replace_id: item.update.body.replace_id | |
}) | |
.then((response) => { | |
for (let i = 0; i < data.length; i++) { | |
if (data[i].id == item.update?.body?.replace_id) { | |
data[i] = response.data?.data[0]; | |
} | |
} | |
}); | |
} | |
return data; | |
}, | |
}); | |
router.beforeEach(async (to) => { | |
if (!homeData.value) return; | |
setTitle(); | |
setBackground(null); | |
setColorPalette(null); | |
if (to.name !== routeName) return; | |
const mutations = homeData.value?.filter(item => item?.update?.when == 'pageLoad') ?? []; | |
mutate(mutations); | |
}); | |
</script> | |
<template> | |
<template v-if="!isMutating"> | |
<component | |
v-for="(render, index) in mutatedData ?? homeData ?? []" | |
:index="index" | |
:key="render.id" | |
:is="render.component" | |
v-bind="render.props" | |
/> | |
</template> | |
</template> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment