b53c7fb736
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>
252 lines
9.9 KiB
TypeScript
252 lines
9.9 KiB
TypeScript
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'};`,
|
||
}
|
||
})
|
||
}
|