Files
vtuber-awards/frontend/src/components/home/useHomeLandingPresentation.ts
T
AzuTear 441ef2b850
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 53s
Update release notes and deploy workspace
2026-06-29 17:49:54 +02:00

256 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, type ComputedRef, type Ref } from 'vue'
import { buildClipEmbed } from '../../lib/clipEmbeds'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory, HomeInteractionModalKind, HomeLandingCategory } from './homeLandingTypes'
type AwardsStore = ReturnType<typeof useAwardsStore>
type AuthStore = ReturnType<typeof useAuthStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show'
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore: AuthStore) {
const role = computed<'guest' | 'user' | 'admin'>(() => {
if (!authStore.session) return 'guest'
return authStore.isAdmin ? 'admin' : 'user'
})
const isGuest = computed(() => role.value === 'guest')
const isUser = computed(() => role.value === 'user')
const isAdmin = computed(() => role.value === 'admin')
const twitchUser = computed(() => authStore.session?.twitchUserId ?? 'local_user')
const siteContent = computed(() => store.overview.siteContent)
const faqItems = computed(() =>
(store.overview.faq ?? []).filter((item) => item?.question && item.answer),
)
const publicStreamUrl = computed(() =>
store.overview.siteContent.streamBanner.liveButtonUrl
|| 'https://twitch.tv/jayuhime',
)
const showDate = computed(() => store.overview.showDate)
const showStartsAt = computed(() => store.overview.showStartsAt || '20:00:00')
const currentYear = computed(() => store.overview.year ? String(store.overview.year) : '')
const displayCategories = computed<HomeDisplayCategory[]>(() =>
store.categories.categories.map((category, index) => ({
id: String(category.id),
groupName: category.groupName,
name: category.name,
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
maxNomineesPerUser: category.maxNomineesPerUser,
candidates: category.candidates,
})),
)
const landingCategories = computed<HomeLandingCategory[]>(() => {
if (store.overview.featuredCategories.length > 0) {
return store.overview.featuredCategories.map((category, index) => {
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName === category.groupName)
const subcategoryNames = matchingSubcategories
.map((item) => item.name.trim())
.filter(Boolean)
return {
id: String(category.id),
groupName: category.groupName,
name: category.groupName,
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
description: category.description,
maxNomineesPerUser: category.maxNomineesPerUser,
subcategoryNames,
}
})
}
const seenGroups = new Set<string>()
return displayCategories.value.flatMap((category, index) => {
const groupKey = category.groupName.trim().toLowerCase()
if (seenGroups.has(groupKey)) {
return []
}
seenGroups.add(groupKey)
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName.trim().toLowerCase() === groupKey)
const subcategoryNames = matchingSubcategories
.map((item) => item.name.trim())
.filter(Boolean)
return [{
id: category.id,
groupName: category.groupName,
name: category.groupName,
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
description: '',
maxNomineesPerUser: category.maxNomineesPerUser,
subcategoryNames,
}]
})
})
const candidateCount = computed(() =>
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
)
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
(store.overview.archiveYears ?? []).length > 0
? (store.overview.archiveYears ?? []).map((entry) => ({ year: entry.year }))
: [{ year: store.overview.year - 1 }],
)
function timelineItem(key: HomeTimelineKey) {
return store.overview.timeline.find((entry) => entry.key === key)
}
function formatRange(key: Exclude<HomeTimelineKey, 'show'>) {
const item = timelineItem(key)
if (!item) return ''
return `${formatDateLabel(item.startsAt)} ${formatDateLabel(item.endsAt)}`
}
function formatTimelineRange(key: HomeTimelineKey) {
const item = timelineItem(key)
if (!item) return 'Noch offen'
return item.startsAt === item.endsAt
? formatDateLabel(item.startsAt)
: `${formatDateLabel(item.startsAt)} ${formatDateLabel(item.endsAt)}`
}
function formatShowDate() {
return formatDateLabel(store.overview.showDate)
}
return {
role,
isGuest,
isUser,
isAdmin,
twitchUser,
siteContent,
faqItems,
publicStreamUrl,
showDate,
showStartsAt,
currentYear,
displayCategories,
landingCategories,
candidateCount,
bootstrapArchiveYears,
formatRange,
formatTimelineRange,
formatShowDate,
initialsFor,
}
}
export function useHomeModalCandidatePresentation(params: {
displayCategories: ComputedRef<HomeDisplayCategory[]>
activeCat: Ref<number>
modal: Ref<null | HomeInteractionModalKind>
votes: Ref<Record<string, number>>
activeCategory: ComputedRef<HomeDisplayCategory | null>
setCat: (index: number) => void
pickNominee: (categoryId: string, index: number) => void
}) {
const {
displayCategories,
activeCat,
modal,
votes,
activeCategory,
setCat,
pickNominee,
} = params
const catList = computed(() => buildCatList(displayCategories, activeCat, votes, setCat))
const noms = computed(() => {
const cat = activeCategory.value
const list = cat?.candidates ?? []
const voteMode = modal.value === 'vote'
return list.map((candidate, idx) => {
const selected = cat ? votes.value[cat.id] === idx : false
const clipUrl = candidate.clipUrl?.trim() || null
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
const clipEmbed = clipUrl && candidate.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
return {
name: candidate.displayName,
handle: candidate.channelSlug,
url: resolveCandidateUrl(candidate.channelUrl, candidate.channelSlug),
platform: candidate.platform,
initials: initialsFor(candidate.displayName),
clipUrl,
clipTitle,
clipPlatform,
clipEmbedUrl: clipEmbed?.src ?? null,
clipEmbedTitle: clipEmbed?.title ?? clipTitle,
idx,
selected,
hasClip: Boolean(clipUrl),
showPick: voteMode,
onPick: () => cat && pickNominee(cat.id, idx),
cardStyle: `display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;transition:all .15s;border:1.5px solid ${selected ? '#8b6cdb;background:#f6f1fd;' : '#ece4f6;background:#fff;'}`,
btnStyle: "flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;transition:all .15s;" + (selected ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#f1ecfb;color:#8b6cdb;'),
btnLabel: selected ? 'Auswahl entfernen' : 'Für Clip voten',
}
})
})
return {
catList,
noms,
}
}
function resolveCandidateUrl(channelUrl: string | null | undefined, channelSlug: string) {
const url = channelUrl?.trim() || channelSlug.trim()
if (!isHttpUrl(url) || url === '#') return null
return url
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function formatDateLabel(value: string) {
if (!value) return 'Noch nicht terminiert'
const date = new Date(`${value}T00:00:00`)
return Number.isNaN(date.getTime())
? value
: date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
}
function initialsFor(value: string) {
return value
.split(/[\s&.-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function buildCatList(
displayCategories: ComputedRef<HomeDisplayCategory[]>,
activeCat: Ref<number>,
votes: Ref<Record<string, number>>,
setCat: (index: number) => void,
) {
const baseRow = "display:flex;align-items:center;gap:10px;padding:11px 13px;border-radius:11px;cursor:pointer;font-size:14px;font-weight:600;transition:all .15s;border:1px solid transparent;outline:none;-webkit-tap-highlight-color:transparent;text-align:left;width:100%;background:transparent;font-family:'Outfit',sans-serif;"
return displayCategories.value.map((category, index) => {
const active = index === activeCat.value
const done = votes.value[category.id] != null
return {
name: category.name,
groupName: category.groupName,
displayName: `${category.groupName} · ${category.name}`,
icon: category.icon,
idx: index,
done,
candidateCount: category.candidates.length,
onClick: () => setCat(index),
rowStyle: baseRow + (active ? 'background:#f1ecfb;border-color:#d8c9f2;color:#5f44ad;' : 'border-color:transparent;color:#6f6685;'),
iconStyle: "flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;" + (active ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#efe7fb;color:#9a7fce;'),
checkStyle: `flex:none;margin-left:auto;color:#1f9d5a;font-size:14px;font-weight:700;display:${done ? 'inline' : 'none'};`,
}
})
}