Add winner archive, host image upload and live demo data
Deliver the demo-ready feature set and seed data so the live site can be presented end to end: - Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager and public archive surface, backed by AddArchivedWinners migration. - Host presentation: host image upload and artist name on SiteSettings with public image endpoint and supporting migrations. - Clip submissions: idempotent table-ensure migration plus current-season demo clips for review workflows. - Demo seed data: sponsors, share links and 2025 archived winners, with a guarded RemoveDemoSeasons cleanup; all seeds guard against real data. - EnsureRuntimeSchemaParity migration to align runtime schema defensively. - Admin/home UI refinements; remove unused team role permissions modal and dead share-quick-links code. All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT) and skip when real season data is present. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ function initialsFor(value: string) {
|
||||
<component
|
||||
:is="winner.url ? 'a' : 'article'"
|
||||
v-for="winner in props.selectedArchive.winners"
|
||||
:key="`${props.selectedArchive.year}-${winner.category}`"
|
||||
:key="`${props.selectedArchive.year}-${winner.category}-${winner.subcategory}-${winner.name}`"
|
||||
:href="winner.url || undefined"
|
||||
:target="winner.url ? '_blank' : undefined"
|
||||
:rel="winner.url ? 'noopener' : undefined"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<span style="position:absolute;top:230px;left:62%;width:8px;height:8px;border-radius:50%;background:#c9b6f0;opacity:.7;"></span>
|
||||
<span style="position:absolute;bottom:120px;left:84%;font-size:14px;color:#e7b13e;animation:twinkle 3.1s ease-in-out 1.4s infinite;">✦</span>
|
||||
|
||||
<img class="home-hero__character" src="/assets/jayu-hero.png" alt="Jayu mit Stern-Pokal" data-hero-char style="position:absolute;top:-10px;right:max(30px,calc(50% - 560px));height:1010px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);" />
|
||||
<img class="home-hero__character" :src="siteContent.hostImageUrl || '/assets/amaterasu2sei_2.png'" alt="Host der VTuber Star Awards" data-hero-char style="position:absolute;top:16px;right:max(-70px,calc(50% - 660px));height:1040px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 52%,rgba(0,0,0,0) 83%);mask-image:linear-gradient(to bottom,#000 52%,rgba(0,0,0,0) 83%);" />
|
||||
<div class="home-hero__veil" style="position:absolute;inset:0;z-index:2;pointer-events:none;background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.86) 26%,rgba(246,240,254,.3) 46%,transparent 62%);"></div>
|
||||
|
||||
<div class="home-hero__content" style="position:relative;z-index:3;max-width:1200px;margin:0 auto;padding:60px 24px 70px;">
|
||||
@@ -95,6 +95,10 @@
|
||||
<div style="font-size:12px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:5px;">Host</div>
|
||||
<div class="home-hero__host-name" style="display:flex;align-items:center;gap:9px;font-family:'Outfit',sans-serif;font-weight:700;font-size:27px;letter-spacing:.5px;color:#5f44ad;line-height:1;margin-bottom:5px;">{{ siteContent.hostDisplayName.toUpperCase() }} <span style="font-size:19px;color:#e7b13e;">✦</span></div>
|
||||
<div style="font-size:14px;color:#8a8398;margin-bottom:14px;">{{ siteContent.hostTagline }}</div>
|
||||
<div v-if="siteContent.hostArtistName" style="display:inline-flex;align-items:center;gap:7px;margin:-2px 0 14px;padding:7px 10px;border-radius:999px;background:rgba(241,236,251,.74);font-size:12px;font-weight:700;letter-spacing:.04em;color:#7b68ad;">
|
||||
<span style="color:#e7b13e;">✧</span>
|
||||
Art by {{ siteContent.hostArtistName }}
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<a
|
||||
v-for="social in hostSocialLinks"
|
||||
@@ -138,6 +142,8 @@ interface HomeSocialLink {
|
||||
interface HomeSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
hostImageUrl: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -14,6 +14,8 @@ interface HomeSocialLink {
|
||||
interface HomeSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
hostImageUrl: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -56,7 +56,6 @@ const nominationCatValue = ref(0)
|
||||
const clipCatValue = ref(0)
|
||||
const clipNomQuery = ref('')
|
||||
const nominationDrafts = ref<Record<number, string[]>>({})
|
||||
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
|
||||
const nominationReviewMode = ref(false)
|
||||
|
||||
watch(
|
||||
@@ -99,39 +98,25 @@ function notifyClipCategoryChange(value: number) {
|
||||
|
||||
function syncNominationDrafts(options: HomeSelectionOption[]) {
|
||||
const nextDrafts: Record<number, string[]> = {}
|
||||
const nextVisibleLinkCounts: Record<number, number> = {}
|
||||
for (const option of options) {
|
||||
const existing = nominationDrafts.value[option.id] ?? []
|
||||
const limit = nominationLinkLimitFor(option.id)
|
||||
nextDrafts[option.id] = createNominationLinkSlots(existing, limit)
|
||||
const filledCount = nextDrafts[option.id].reduce((count, link, index) => link.trim() ? index + 1 : count, 0)
|
||||
nextVisibleLinkCounts[option.id] = Math.min(limit, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
|
||||
}
|
||||
nominationDrafts.value = nextDrafts
|
||||
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
|
||||
}
|
||||
|
||||
function nominationLinksFor(categoryIndex: number) {
|
||||
return nominationDrafts.value[categoryIndex] ?? createNominationLinkSlots([], nominationLinkLimitFor(categoryIndex))
|
||||
}
|
||||
|
||||
function nominationVisibleLinkCountFor(categoryIndex: number) {
|
||||
return nominationVisibleLinkCounts.value[categoryIndex] ?? 1
|
||||
}
|
||||
|
||||
function compactNominationCategory(categoryIndex: number) {
|
||||
const limit = nominationLinkLimitFor(categoryIndex)
|
||||
const links = nominationLinksFor(categoryIndex)
|
||||
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, limit)
|
||||
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
|
||||
const nextVisibleCount = Math.max(1, compactedLinks.length)
|
||||
nominationDrafts.value = {
|
||||
...nominationDrafts.value,
|
||||
[categoryIndex]: nextLinks,
|
||||
}
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[categoryIndex]: nextVisibleCount,
|
||||
[categoryIndex]: createNominationLinkSlots(compactedLinks, limit),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,9 +148,6 @@ const activeNominationCategory = computed(() =>
|
||||
)
|
||||
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
|
||||
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
|
||||
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
|
||||
const activeNominationLinkLimit = computed(() => nominationLinkLimitFor(nominationCatValue.value))
|
||||
const activeNominationRemainingLinks = computed(() => Math.max(0, activeNominationLinkLimit.value - activeNominationVisibleLinkCount.value))
|
||||
const nominationReviewItems = computed(() =>
|
||||
props.catOptions
|
||||
.map((option) => ({
|
||||
@@ -239,36 +221,6 @@ function editNominationCategory(categoryIndex: number) {
|
||||
nominationReviewMode.value = false
|
||||
}
|
||||
|
||||
function addNominationLinkField() {
|
||||
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
|
||||
const limit = nominationLinkLimitFor(nominationCatValue.value)
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[nominationCatValue.value]: Math.min(limit, currentCount + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function removeNominationLinkField(index: number) {
|
||||
if (index <= 0) return
|
||||
const compactedLinks = nominationLinksFor(nominationCatValue.value)
|
||||
.filter((_, linkIndex) => linkIndex !== index)
|
||||
.map((link) => link.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, nominationLinkLimitFor(nominationCatValue.value))
|
||||
const limit = nominationLinkLimitFor(nominationCatValue.value)
|
||||
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
|
||||
const previousVisibleCount = nominationVisibleLinkCountFor(nominationCatValue.value)
|
||||
const nextVisibleCount = Math.max(1, Math.min(limit, Math.max(previousVisibleCount - 1, compactedLinks.length)))
|
||||
nominationDrafts.value = {
|
||||
...nominationDrafts.value,
|
||||
[nominationCatValue.value]: nextLinks,
|
||||
}
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[nominationCatValue.value]: nextVisibleCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNominationDrafts() {
|
||||
if (nominationHasErrors.value || nominationTotalLinks.value === 0 || props.submitting) return
|
||||
await props.submitNomination({
|
||||
@@ -371,11 +323,7 @@ function stripCategoryIcon(value: string) {
|
||||
:category-name="activeNominationCategory ? activeNominationCategory.name || stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||
:links="activeNominationLinks"
|
||||
:errors="activeNominationErrors"
|
||||
:visible-link-count="activeNominationVisibleLinkCount"
|
||||
:remaining-link-count="activeNominationRemainingLinks"
|
||||
:on-link-input="onNominationLinkInput"
|
||||
:on-add-link-field="addNominationLinkField"
|
||||
:on-remove-link-field="removeNominationLinkField"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,7 @@ const props = defineProps<{
|
||||
categoryName: string
|
||||
links: string[]
|
||||
errors: string[]
|
||||
visibleLinkCount: number
|
||||
remainingLinkCount: number
|
||||
onLinkInput: (index: number, value: string) => void
|
||||
onAddLinkField: () => void
|
||||
onRemoveLinkField: (index: number) => void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -20,17 +16,9 @@ const props = defineProps<{
|
||||
</header>
|
||||
|
||||
<div class="home-nomination-pane__fields">
|
||||
<label v-for="(_, index) in props.links.slice(0, props.visibleLinkCount)" :key="index" class="home-nomination-field">
|
||||
<label v-for="(_, index) in props.links" :key="index" class="home-nomination-field">
|
||||
<span class="home-nomination-field__header">
|
||||
<span>{{ index === 0 ? 'Link 1' : `Link ${index + 1} optional` }}</span>
|
||||
<button
|
||||
v-if="index > 0"
|
||||
type="button"
|
||||
class="home-nomination-field__remove"
|
||||
@click.prevent="props.onRemoveLinkField(index)"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:value="props.links[index]"
|
||||
@@ -44,16 +32,5 @@ const props = defineProps<{
|
||||
<small v-else>Offizieller Kanal- oder Stream-Link.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="props.remainingLinkCount > 0"
|
||||
type="button"
|
||||
class="home-nomination-pane__add-link"
|
||||
@click="props.onAddLinkField"
|
||||
>
|
||||
<span>+ Weiteren Link hinzufügen</span>
|
||||
<small>Noch {{ props.remainingLinkCount }} möglich</small>
|
||||
</button>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,12 @@ const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.va
|
||||
const safeNewsletterUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
|
||||
)
|
||||
const safeShareXUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.shareXUrl) ? props.siteContent.shareXUrl : '',
|
||||
)
|
||||
const safeShareDiscordUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.shareDiscordUrl) ? props.siteContent.shareDiscordUrl : '',
|
||||
)
|
||||
const isSponsorFooterModal = computed(() => activeFooterLink.value?.key === 'sponsors')
|
||||
const footerModalEyebrow = computed(() =>
|
||||
isSponsorFooterModal.value ? 'Support the show' : 'Footer Seite',
|
||||
@@ -164,8 +170,8 @@ onUnmounted(() => {
|
||||
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
|
||||
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
|
||||
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
|
||||
<a v-if="props.siteContent.shareXUrl" :href="props.siteContent.shareXUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
|
||||
<a v-if="props.siteContent.shareDiscordUrl" :href="props.siteContent.shareDiscordUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
|
||||
<a v-if="safeShareXUrl" :href="safeShareXUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
|
||||
<a v-if="safeShareDiscordUrl" :href="safeShareDiscordUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
|
||||
<a href="#" @click.prevent="copyLink" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>{{ linkCopied ? 'Kopiert ✓' : 'Link kopieren' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -703,47 +703,6 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
font-weight:800;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link{
|
||||
width:100%;
|
||||
margin-top:14px;
|
||||
padding:13px 16px;
|
||||
border:1px dashed #cdbcf0;
|
||||
border-radius:16px;
|
||||
background:linear-gradient(135deg,rgba(255,255,255,.9),rgba(246,240,254,.88));
|
||||
color:#7658c4;
|
||||
cursor:pointer;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
font-family:'Outfit',sans-serif;
|
||||
text-align:left;
|
||||
transition:transform .16s ease,border-color .16s ease,box-shadow .16s ease,background .16s ease;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link:hover{
|
||||
border-color:#a98ddb;
|
||||
background:#fff;
|
||||
box-shadow:0 12px 28px rgba(82,61,128,.09);
|
||||
transform:translateY(-1px);
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link span{
|
||||
font-size:14px;
|
||||
font-weight:900;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link small{
|
||||
flex:none;
|
||||
padding:4px 8px;
|
||||
border-radius:999px;
|
||||
background:#f0e8fb;
|
||||
color:#7d60c6;
|
||||
font-size:11px;
|
||||
font-weight:900;
|
||||
line-height:1;
|
||||
}
|
||||
|
||||
.home-nomination-review__hero h4,
|
||||
.home-missing-votes h4{
|
||||
margin:0 0 8px;
|
||||
@@ -2249,11 +2208,6 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
min-height:0!important;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link{
|
||||
align-items:flex-start!important;
|
||||
flex-direction:column!important;
|
||||
}
|
||||
|
||||
.home-nomination-review__stats{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user