Newer
Older
navi-1 / webclient / src / components / ui / CardGrid.vue
<template>
  <div class="card-grid">
    <div v-if="data.title" class="card-grid-title">{{ data.title }}</div>
    <div class="card-grid-items">
      <div
        v-for="card in visibleCards"
        :key="card.id"
        class="card-grid-item"
        tabindex="0"
        @click="openCard(card)"
        @keydown.enter="openCard(card)"
      >
        <img v-if="card.image?.trim()" :src="card.image" class="card-image" alt="" loading="lazy" />
        <div class="card-no-image" v-else>
          <i class="ph ph-image"></i>
        </div>
        <div class="card-content">
          <div class="card-title">{{ card.title }}</div>
          <div v-if="card.subtitle" class="card-subtitle">{{ card.subtitle }}</div>
          <div v-if="card.meta?.length" class="card-meta">
            <span v-for="(m, i) in card.meta" :key="i" class="card-meta-item">
              <span class="card-meta-label">{{ m.label }}</span>
              <span class="card-meta-value">{{ m.value }}</span>
            </span>
          </div>
          <div v-if="card.description" class="card-description">{{ card.description }}</div>
        </div>
      </div>
    </div>
    <div v-if="hasMore" class="card-grid-more">
      <span class="card-grid-more-text">+{{ remainingCount }} more</span>
    </div>

    <GnModal :open="!!activeCard" @update:open="closeCard">
      <template #title>
        <h2 class="card-modal-title">{{ activeCard?.title }}</h2>
      </template>
      <div v-if="activeCard" class="card-modal">
        <img v-if="activeCard.image?.trim()" :src="activeCard.image" class="card-modal-image" alt="" />
        <div class="card-modal-content">
          <div v-if="activeCard.subtitle" class="card-modal-subtitle">{{ activeCard.subtitle }}</div>

          <div v-if="activeCard.meta?.length" class="card-modal-section">
            <div class="card-modal-section-title">Overview</div>
            <div class="card-modal-meta-grid">
              <div v-for="(m, i) in activeCard.meta" :key="i" class="card-modal-meta-cell">
                <div class="card-modal-meta-label">{{ m.label }}</div>
                <div class="card-modal-meta-value">{{ m.value }}</div>
              </div>
            </div>
          </div>

          <div v-if="activeCard.description" class="card-modal-section">
            <div class="card-modal-section-title">Description</div>
            <p class="card-modal-text">{{ activeCard.description }}</p>
          </div>

          <div v-if="activeCard.details?.length" class="card-modal-section">
            <div class="card-modal-section-title">Details</div>
            <dl class="card-modal-details">
              <div v-for="(d, i) in activeCard.details" :key="i" class="card-modal-detail-row">
                <dt>{{ d.label }}</dt>
                <dd>{{ d.value }}</dd>
              </div>
            </dl>
          </div>

          <div v-if="activeCard.actions?.length" class="card-modal-actions">
            <a
              v-for="(a, i) in activeCard.actions"
              :key="i"
              :href="a.url"
              target="_blank"
              rel="noopener noreferrer"
              class="card-modal-action"
            >
              {{ a.label }}
            </a>
          </div>
        </div>
      </div>
    </GnModal>
  </div>
</template>

<script setup>
import { ref, computed, watchEffect } from 'vue'

const props = defineProps({
  data: { type: Object, default: () => ({}) }
})

const activeCard = ref(null)
const DEFAULT_VISIBLE = 4

const cards = computed(() => Array.isArray(props.data.cards) ? props.data.cards : [])
const visibleCards = computed(() => cards.value.slice(0, DEFAULT_VISIBLE))
const hasMore = computed(() => cards.value.length > DEFAULT_VISIBLE)
const remainingCount = computed(() => cards.value.length - DEFAULT_VISIBLE)

watchEffect(() => {
  if (!Array.isArray(props.data?.cards)) {
    console.warn('[CardGrid] payload is missing a "cards" array', props.data)
  }
})

function openCard(card) {
  activeCard.value = card
}

function closeCard() {
  activeCard.value = null
}
</script>

<style scoped>
.card-grid {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.card-grid-title {
  font-size: 1rem;
  font-weight: 600;
  color: var(--gn-color-text-light);
}

.card-grid-items {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
}

@media (max-width: 640px) {
  .card-grid-items {
    grid-template-columns: 1fr;
  }
}

.card-grid-item {
  display: flex;
  flex-direction: column;
  background: color-mix(in srgb, var(--gn-color-grey) 12%, var(--gn-surface-panel));
  border: 1px solid var(--gn-border-color-muted);
  overflow: hidden;
  cursor: pointer;
  transition: border-color 0.15s, transform 0.1s;
}

.card-grid-item:hover,
.card-grid-item:focus {
  border-color: var(--gn-color-secondary);
  outline: none;
}

.card-grid-item:active {
  transform: scale(0.99);
}

.card-image {
  width: 100%;
  height: 140px;
  object-fit: cover;
  background: var(--gn-surface-panel);
}

.card-no-image {
  width: 100%;
  height: 140px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--gn-color-text-dark);
  font-size: 2rem;
  background: var(--gn-surface-panel);
}

.card-content {
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex: 1;
}

.card-title {
  font-weight: 600;
  font-size: 0.95rem;
  color: var(--gn-color-text-light);
  line-height: 1.3;
}

.card-subtitle {
  font-size: 0.8rem;
  color: var(--gn-color-text-dark);
}

.card-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.card-meta-item {
  display: flex;
  gap: 4px;
  font-size: 0.75rem;
}

.card-meta-label {
  color: var(--gn-color-text-dark);
}

.card-meta-value {
  color: var(--gn-color-text-light);
  font-weight: 500;
}

.card-description {
  font-size: 0.8rem;
  color: var(--gn-color-text-light);
  line-height: 1.4;
  opacity: 0.85;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.card-grid-more {
  text-align: center;
  font-size: 0.8rem;
  color: var(--gn-color-text-dark);
}

.card-modal-image {
  width: 100%;
  max-height: 320px;
  object-fit: cover;
  background: var(--gn-surface-panel);
}

.card-modal-content {
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.card-modal-title {
  margin: 0;
  font-size: 1.15rem;
  font-weight: 600;
  color: var(--gn-color-text-light);
  text-transform: none;
}

.card-modal-subtitle {
  color: var(--gn-color-text-dark);
  font-size: 0.9rem;
}

.card-modal-section {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.card-modal-section-title {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--gn-color-text-dark);
}

.card-modal-meta-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 10px;
}

.card-modal-meta-cell {
  background: color-mix(in srgb, var(--gn-color-grey) 12%, var(--gn-surface-panel));
  border: 1px solid var(--gn-border-color-muted);
  padding: 10px;
}

.card-modal-meta-label {
  font-size: 0.7rem;
  color: var(--gn-color-text-dark);
  margin-bottom: 4px;
}

.card-modal-meta-value {
  font-size: 0.9rem;
  color: var(--gn-color-text-light);
  font-weight: 500;
}

.card-modal-text {
  margin: 0;
  line-height: 1.5;
  color: var(--gn-color-text-light);
}

.card-modal-details {
  margin: 0;
  display: grid;
  gap: 8px;
}

.card-modal-detail-row {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 8px 0;
  border-bottom: 1px solid var(--gn-border-color-muted);
}

.card-modal-detail-row:last-child {
  border-bottom: none;
}

.card-modal-detail-row dt {
  color: var(--gn-color-text-dark);
  font-size: 0.85rem;
}

.card-modal-detail-row dd {
  margin: 0;
  color: var(--gn-color-text-light);
  font-size: 0.85rem;
  text-align: right;
  word-break: break-word;
}

.card-modal-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.card-modal-action {
  display: inline-flex;
  align-items: center;
  padding: 8px 14px;
  background: color-mix(in srgb, var(--gn-color-secondary) 14%, var(--gn-surface-panel));
  color: var(--gn-color-secondary);
  text-decoration: none;
  font-size: 0.85rem;
  font-weight: 500;
}

.card-modal-action:hover {
  background: var(--gn-border-color-muted);
}
</style>