feat(fc2c-i): rich artist page with sparkline and FC-4 stubs
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<v-container fluid class="py-6">
|
||||||
|
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
||||||
|
<v-progress-circular indeterminate color="accent" size="36" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-else-if="store.notFound" type="warning" variant="tonal">
|
||||||
|
Artist not found.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert v-else-if="store.error" type="error" variant="tonal" closable>
|
||||||
|
{{ store.error }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<template v-else-if="store.overview">
|
||||||
|
<header class="fc-artist__head">
|
||||||
|
<h1 class="fc-h1">{{ store.overview.name }}</h1>
|
||||||
|
<div class="fc-artist__stats">
|
||||||
|
<span>{{ store.overview.image_count }} images</span>
|
||||||
|
<span v-if="dateRange">· {{ dateRange }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="fc-artist__fc4">
|
||||||
|
<v-chip
|
||||||
|
v-for="b in ['Subscription', 'Sources', 'Credential health']"
|
||||||
|
:key="b" disabled size="small" variant="outlined"
|
||||||
|
prepend-icon="mdi-clock-outline"
|
||||||
|
>{{ b }} · Coming in FC-4</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="store.overview.cooccurring_tags.length" class="fc-artist__sec">
|
||||||
|
<h2 class="fc-h2">Frequent tags</h2>
|
||||||
|
<div class="fc-artist__tags">
|
||||||
|
<v-chip
|
||||||
|
v-for="t in store.overview.cooccurring_tags" :key="t.id"
|
||||||
|
size="small" @click="openTag(t.id)"
|
||||||
|
>{{ t.name }} <span class="fc-artist__tagc">{{ t.count }}</span></v-chip>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="store.overview.activity.length" class="fc-artist__sec">
|
||||||
|
<h2 class="fc-h2">Activity</h2>
|
||||||
|
<svg class="fc-artist__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
||||||
|
preserveAspectRatio="none" role="img" aria-label="posts over time">
|
||||||
|
<polyline :points="sparkPoints" fill="none"
|
||||||
|
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="store.overview.sources.length" class="fc-artist__sec">
|
||||||
|
<h2 class="fc-h2">Sources</h2>
|
||||||
|
<v-table density="compact">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="s in store.overview.sources" :key="s.id">
|
||||||
|
<td>{{ s.platform }}</td>
|
||||||
|
<td class="fc-artist__url">{{ s.url }}</td>
|
||||||
|
<td class="text-right">{{ s.image_count }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="fc-artist__sec">
|
||||||
|
<h2 class="fc-h2">Images</h2>
|
||||||
|
<MasonryGrid
|
||||||
|
:items="store.images"
|
||||||
|
:loading="store.imagesLoading"
|
||||||
|
:has-more="store.hasMoreImages"
|
||||||
|
@load-more="store.loadMoreImages(slug)"
|
||||||
|
@open="openImage"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useArtistStore } from '../stores/artist.js'
|
||||||
|
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useArtistStore()
|
||||||
|
|
||||||
|
const slug = computed(() => route.params.slug)
|
||||||
|
|
||||||
|
watch(slug, (s) => { if (s) store.load(s) }, { immediate: true })
|
||||||
|
|
||||||
|
const dateRange = computed(() => {
|
||||||
|
const r = store.overview?.date_range
|
||||||
|
if (!r || !r.min) return null
|
||||||
|
const fmt = (iso) => iso.slice(0, 10)
|
||||||
|
return r.min === r.max ? fmt(r.min) : `${fmt(r.min)} → ${fmt(r.max)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const sparkW = 600
|
||||||
|
const sparkH = 80
|
||||||
|
const sparkPoints = computed(() => {
|
||||||
|
const a = store.overview?.activity ?? []
|
||||||
|
if (a.length === 0) return ''
|
||||||
|
const max = Math.max(...a.map(p => p.count), 1)
|
||||||
|
const stepX = a.length > 1 ? sparkW / (a.length - 1) : 0
|
||||||
|
return a.map((p, i) => {
|
||||||
|
const x = i * stepX
|
||||||
|
const y = sparkH - (p.count / max) * (sparkH - 4) - 2
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
function openImage(id) {
|
||||||
|
router.push({ name: 'gallery', query: { image: id } })
|
||||||
|
}
|
||||||
|
function openTag(tagId) {
|
||||||
|
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-h1 {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 32px; font-weight: 500;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-h2 {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 20px; font-weight: 500; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fc-artist__loading { display: flex; justify-content: center; padding: 64px 0; }
|
||||||
|
.fc-artist__head { margin-bottom: 12px; }
|
||||||
|
.fc-artist__stats { opacity: 0.75; margin-top: 4px; }
|
||||||
|
.fc-artist__fc4 { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||||
|
.fc-artist__sec { margin-bottom: 28px; }
|
||||||
|
.fc-artist__tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.fc-artist__tagc { opacity: 0.6; margin-left: 4px; font-variant-numeric: tabular-nums; }
|
||||||
|
.fc-artist__spark { width: 100%; height: 80px; }
|
||||||
|
.fc-artist__url {
|
||||||
|
max-width: 380px; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user