Files
vtuber-awards/frontend/src/components/home/useHomeLandingPresentation.ts
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00

252 lines
9.9 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.showStreamUrl || '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 candidateCount = matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0)
return {
id: String(category.id),
groupName: category.groupName,
name: category.groupName,
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
description: category.description,
maxNomineesPerUser: category.maxNomineesPerUser,
candidateCount,
subcategoryCount: matchingSubcategories.length,
}
})
}
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)
return [{
id: category.id,
groupName: category.groupName,
name: category.groupName,
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
description: '',
maxNomineesPerUser: category.maxNomineesPerUser,
candidateCount: matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0),
subcategoryCount: matchingSubcategories.length,
}]
})
})
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 }))
: store.overview.winnersPreview.length > 0
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ 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'};`,
}
})
}