add party info grid components
This commit is contained in:
parent
22d3ac625f
commit
d39079c814
7 changed files with 669 additions and 0 deletions
29
src/lib/components/party/info/DescriptionTile.svelte
Normal file
29
src/lib/components/party/info/DescriptionTile.svelte
Normal 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>
|
||||
73
src/lib/components/party/info/InfoTile.svelte
Normal file
73
src/lib/components/party/info/InfoTile.svelte
Normal 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>
|
||||
140
src/lib/components/party/info/PartyInfoGrid.svelte
Normal file
140
src/lib/components/party/info/PartyInfoGrid.svelte
Normal 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>
|
||||
89
src/lib/components/party/info/PerformanceTile.svelte
Normal file
89
src/lib/components/party/info/PerformanceTile.svelte
Normal 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>
|
||||
57
src/lib/components/party/info/RaidTile.svelte
Normal file
57
src/lib/components/party/info/RaidTile.svelte
Normal 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>
|
||||
83
src/lib/components/party/info/SettingsTile.svelte
Normal file
83
src/lib/components/party/info/SettingsTile.svelte
Normal 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>
|
||||
198
src/lib/components/party/info/VideoTile.svelte
Normal file
198
src/lib/components/party/info/VideoTile.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue