Skip to content

Instantly share code, notes, and snippets.

@StoneyEagle
Last active November 15, 2024 16:10
Show Gist options
  • Save StoneyEagle/cdf944de9f8699b01638f11f71432e85 to your computer and use it in GitHub Desktop.
Save StoneyEagle/cdf944de9f8699b01638f11f71432e85 to your computer and use it in GitHub Desktop.
Configuration-Driven UI
[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,
}
})
}
})
]
});
}
}
<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>
<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>
<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