add party info grid components

This commit is contained in:
Justin Edmund 2025-12-21 02:56:49 -08:00
parent 22d3ac625f
commit d39079c814
7 changed files with 669 additions and 0 deletions

View file

@ -0,0 +1,29 @@
<script lang="ts">
import InfoTile from './InfoTile.svelte'
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
interface Props {
description?: string
onOpen: () => void
}
let { description, onOpen }: Props = $props()
</script>
<InfoTile label="Description" clickable onclick={onOpen} class="description-tile">
{#if description}
<DescriptionRenderer content={description} truncate={true} maxLines={4} />
{:else}
<span class="empty-state">No description</span>
{/if}
</InfoTile>
<style lang="scss">
@use '$src/themes/typography' as *;
.empty-state {
font-size: $font-regular;
color: var(--text-tertiary);
font-style: italic;
}
</style>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
label?: string
clickable?: boolean
onclick?: () => void
class?: string
children: Snippet
}
let { label, clickable = false, onclick, class: className = '', children }: Props = $props()
</script>
<div
class="info-tile {className}"
class:clickable
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : undefined}
onclick={clickable ? onclick : undefined}
onkeydown={clickable ? (e) => e.key === 'Enter' && onclick?.() : undefined}
>
{#if label}
<h3 class="tile-label">{label}</h3>
{/if}
<div class="tile-content">
{@render children()}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/typography' as *;
.info-tile {
background: var(--card-bg);
border: 0.5px solid var(--button-bg);
border-radius: $card-corner;
padding: $unit-2x;
display: flex;
flex-direction: column;
gap: $unit;
&.clickable {
cursor: pointer;
@include smooth-transition($duration-quick, box-shadow, transform);
&:hover {
box-shadow: $card-elevation-hover;
}
&:active {
transform: scale(0.99);
}
}
.tile-label {
font-size: $font-small;
font-weight: $medium;
color: var(--text-secondary);
margin: 0;
}
.tile-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import type { Party } from '$lib/types/api/party'
import DescriptionTile from './DescriptionTile.svelte'
import RaidTile from './RaidTile.svelte'
import PerformanceTile from './PerformanceTile.svelte'
import SettingsTile from './SettingsTile.svelte'
import VideoTile from './VideoTile.svelte'
interface Props {
party: Party
canEdit: boolean
onOpenDescription: () => void
onOpenEdit?: () => void
}
let { party, canEdit, onOpenDescription, onOpenEdit }: Props = $props()
// Check if data exists for each tile
const hasDescription = $derived(!!party.description)
const hasRaid = $derived(!!party.raid)
const hasPerformanceData = $derived(
party.clearTime != null ||
party.buttonCount != null ||
party.chainCount != null ||
party.summonCount != null
)
const hasVideo = $derived(!!party.videoUrl)
// Show tile if: has data OR (is owner's team and can prompt to fill)
const showDescription = $derived(hasDescription || canEdit)
const showRaid = $derived(hasRaid || canEdit)
const showPerformance = $derived(hasPerformanceData || canEdit)
const showVideo = $derived(hasVideo || canEdit)
// Settings always shown - they have default values
</script>
<div class="party-info-grid">
<!-- Row 1: Description + Raid -->
<div class="row row-1">
{#if showDescription}
<DescriptionTile description={party.description} onOpen={onOpenDescription} />
{/if}
{#if showRaid}
<RaidTile raid={party.raid} />
{/if}
</div>
<!-- Row 2: Performance, Settings, Video -->
<div class="row row-2">
{#if showPerformance}
<PerformanceTile
clearTime={party.clearTime}
buttonCount={party.buttonCount}
chainCount={party.chainCount}
summonCount={party.summonCount}
clickable={canEdit}
onclick={onOpenEdit}
/>
{/if}
<SettingsTile
fullAuto={party.fullAuto}
autoGuard={party.autoGuard}
autoSummon={party.autoSummon}
chargeAttack={party.chargeAttack}
clickable={canEdit}
onclick={onOpenEdit}
/>
{#if showVideo}
<VideoTile videoUrl={party.videoUrl} />
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
.party-info-grid {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.row {
display: grid;
gap: $unit-2x;
}
.row-1 {
grid-template-columns: 2fr 1fr;
// If only one item, let it take full width
&:has(> :only-child) {
grid-template-columns: 1fr;
}
}
.row-2 {
grid-template-columns: repeat(3, 1fr);
// Adjust columns based on number of children
&:has(> :nth-child(2):last-child) {
grid-template-columns: repeat(2, 1fr);
}
&:has(> :only-child) {
grid-template-columns: 1fr;
}
}
// Tablet breakpoint
@media (max-width: 1024px) {
.row-1 {
grid-template-columns: 1fr;
}
.row-2 {
grid-template-columns: repeat(2, 1fr);
&:has(> :nth-child(3)) {
grid-template-columns: repeat(2, 1fr);
// Third item spans full width
> :nth-child(3) {
grid-column: 1 / -1;
}
}
}
}
// Mobile breakpoint
@media (max-width: 768px) {
.row-1,
.row-2 {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,89 @@
<script lang="ts">
import InfoTile from './InfoTile.svelte'
interface Props {
clearTime?: number
buttonCount?: number
chainCount?: number
summonCount?: number
clickable?: boolean
onclick?: () => void
}
let { clearTime, buttonCount, chainCount, summonCount, clickable = false, onclick }: Props =
$props()
function formatClearTime(seconds?: number): string {
if (seconds == null || seconds <= 0) return '—'
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
function formatCount(count?: number): string {
if (count == null) return '—'
return count.toString()
}
const bcsDisplay = $derived(() => {
const b = formatCount(buttonCount)
const c = formatCount(chainCount)
const s = formatCount(summonCount)
return `${b}B ${c}C ${s}S`
})
</script>
<InfoTile label="Performance" class="performance-tile" {clickable} {onclick}>
<div class="performance-content">
<div class="clear-time">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="value">{formatClearTime(clearTime)}</span>
</div>
<div class="bcs-counts">
<span class="bcs-value">{bcsDisplay()}</span>
</div>
</div>
</InfoTile>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.performance-content {
display: flex;
flex-direction: column;
gap: $unit;
}
.clear-time {
display: flex;
align-items: center;
gap: $unit;
.icon {
width: 20px;
height: 20px;
color: var(--text-secondary);
}
.value {
font-size: $font-large;
font-weight: $bold;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
}
.bcs-counts {
.bcs-value {
font-size: $font-medium;
font-weight: $medium;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
letter-spacing: 0.05em;
}
}
</style>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import InfoTile from './InfoTile.svelte'
import type { Raid } from '$lib/types/api/entities'
interface Props {
raid?: Raid
}
let { raid }: Props = $props()
const raidName = $derived(() => {
if (!raid) return null
if (typeof raid.name === 'string') return raid.name
return raid.name?.en || raid.name?.ja || 'Unknown Raid'
})
</script>
<InfoTile label="Raid" class="raid-tile">
{#if raid}
<div class="raid-info">
<span class="raid-name">{raidName()}</span>
{#if raid.group?.difficulty}
<span class="raid-difficulty">Lv. {raid.group.difficulty}</span>
{/if}
</div>
{:else}
<span class="empty-state">No raid selected</span>
{/if}
</InfoTile>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.raid-info {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.raid-name {
font-size: $font-medium;
font-weight: $bold;
color: var(--text-primary);
}
.raid-difficulty {
font-size: $font-regular;
color: var(--text-secondary);
}
.empty-state {
font-size: $font-regular;
color: var(--text-tertiary);
font-style: italic;
}
</style>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import InfoTile from './InfoTile.svelte'
interface Props {
fullAuto?: boolean
autoGuard?: boolean
autoSummon?: boolean
chargeAttack?: boolean
clickable?: boolean
onclick?: () => void
}
let { fullAuto, autoGuard, autoSummon, chargeAttack, clickable = false, onclick }: Props =
$props()
interface Setting {
key: string
label: string
active: boolean
}
const settings: Setting[] = $derived([
{ key: 'chargeAttack', label: `Charge Attack ${chargeAttack ?? true ? 'On' : 'Off'}`, active: chargeAttack ?? true },
{ key: 'fullAuto', label: `Full Auto ${fullAuto ? 'On' : 'Off'}`, active: fullAuto ?? false },
{ key: 'autoSummon', label: `Auto Summon ${autoSummon ? 'On' : 'Off'}`, active: autoSummon ?? false },
{ key: 'autoGuard', label: `Auto Guard ${autoGuard ? 'On' : 'Off'}`, active: autoGuard ?? false }
])
</script>
<InfoTile label="Settings" class="settings-tile" {clickable} {onclick}>
<div class="settings-tokens">
{#each settings as setting (setting.key)}
<span class="token {setting.key}" class:on={setting.active} class:off={!setting.active}>
{setting.label}
</span>
{/each}
</div>
</InfoTile>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/typography' as *;
.settings-tokens {
display: flex;
flex-wrap: wrap;
gap: $unit;
}
.token {
display: inline-flex;
align-items: center;
padding: $unit-three-quarter $unit-2x;
border-radius: $full-corner;
font-size: $font-small;
font-weight: $bold;
line-height: 1.4;
text-align: center;
user-select: none;
background: var(--input-bg);
&.off {
color: var(--text-secondary);
}
&.chargeAttack.on {
background: var(--charge-attack-bg);
color: var(--charge-attack-text);
}
&.fullAuto.on,
&.autoSummon.on {
background: var(--full-auto-bg);
color: var(--full-auto-text);
}
&.autoGuard.on {
background: var(--auto-guard-bg);
color: var(--auto-guard-text);
}
}
</style>

View file

@ -0,0 +1,198 @@
<script lang="ts">
import InfoTile from './InfoTile.svelte'
interface Props {
videoUrl?: string
}
let { videoUrl }: Props = $props()
// State for video playback
let isPlaying = $state(false)
let videoTitle = $state<string | null>(null)
function extractYoutubeId(url: string): string | null {
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
return match?.[1] ?? null
}
const videoId = $derived(videoUrl ? extractYoutubeId(videoUrl) : null)
const thumbnailUrl = $derived(
videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null
)
const embedUrl = $derived(
videoId ? `https://www.youtube.com/embed/${videoId}?autoplay=1` : null
)
// Fetch video title when videoId changes
$effect(() => {
const id = videoId
if (!id) {
videoTitle = null
return
}
const controller = new AbortController()
fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${id}&format=json`,
{ signal: controller.signal }
)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.title) {
videoTitle = data.title
}
})
.catch(() => {
// Ignore fetch errors
})
return () => controller.abort()
})
// Reset playing state when videoUrl changes
$effect(() => {
videoUrl
isPlaying = false
})
function handlePlay() {
isPlaying = true
}
</script>
<InfoTile label="Video" class="video-tile">
{#if videoUrl && videoId}
<div class="video-container">
{#if isPlaying && embedUrl}
<div class="embed-container">
<iframe
src={embedUrl}
title={videoTitle ?? 'YouTube video'}
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
{:else if thumbnailUrl}
<button type="button" class="thumbnail-button" onclick={handlePlay}>
<div class="thumbnail-container">
<img src={thumbnailUrl} alt={videoTitle ?? 'Video thumbnail'} class="thumbnail" />
<div class="play-overlay">
<svg viewBox="0 0 68 48" class="play-icon">
<path
d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"
fill="#f00"
/>
<path d="M45 24L27 14v20" fill="#fff" />
</svg>
</div>
</div>
</button>
{/if}
{#if videoTitle}
<p class="video-title">{videoTitle}</p>
{/if}
</div>
{:else}
<span class="empty-state">No video</span>
{/if}
</InfoTile>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/effects' as *;
@use '$src/themes/typography' as *;
.video-container {
display: flex;
flex-direction: column;
gap: $unit;
}
.thumbnail-button {
display: block;
width: 100%;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.thumbnail-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: $card-corner;
overflow: hidden;
}
.thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
.play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.1);
@include smooth-transition($duration-quick, background);
.play-icon {
width: 68px;
height: 48px;
opacity: 0.9;
@include smooth-transition($duration-quick, opacity, transform);
}
}
.thumbnail-button:hover .play-overlay {
background: rgba(0, 0, 0, 0.2);
.play-icon {
opacity: 1;
transform: scale(1.1);
}
}
.embed-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: $card-corner;
overflow: hidden;
iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
}
.video-title {
margin: 0;
font-size: $font-small;
font-weight: $medium;
color: var(--text-primary);
line-height: 1.3;
// Truncate to 2 lines
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.empty-state {
font-size: $font-regular;
color: var(--text-tertiary);
font-style: italic;
}
</style>