197 lines
5.3 KiB
Vue
197 lines
5.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
interface JournalItem {
|
|
id: string
|
|
platform: 'substack' | 'gitea' | 'github' | 'instagram' | 'castopod' | 'blog' | 'linkedin'
|
|
hashtag: string
|
|
date: string
|
|
titre: string
|
|
extrait: string
|
|
url: string
|
|
thumbnail: string | null
|
|
}
|
|
|
|
interface JournalPayload {
|
|
generatedAt: string
|
|
items: JournalItem[]
|
|
counts?: Record<string, number>
|
|
}
|
|
|
|
const items = ref<JournalItem[]>([])
|
|
const filters = ref<Record<string, boolean>>({})
|
|
const platformFilter = ref<string | null>(null)
|
|
const loading = ref(true)
|
|
const errored = ref(false)
|
|
const empty = ref(false)
|
|
|
|
const JOURNAL_URL =
|
|
(import.meta as unknown as { env: Record<string, string | undefined> }).env
|
|
.PUBLIC_JOURNAL_URL || 'https://data.trans-former.fr/journal.json'
|
|
|
|
const STORAGE_KEY = 'tf-hashtag-filters'
|
|
|
|
const loadFilters = () => {
|
|
try {
|
|
filters.value = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
|
|
} catch {
|
|
filters.value = {}
|
|
}
|
|
}
|
|
|
|
const onFilterChange = (e: Event) => {
|
|
const ce = e as CustomEvent
|
|
if (ce.detail && typeof ce.detail === 'object') {
|
|
filters.value = { ...ce.detail }
|
|
}
|
|
}
|
|
|
|
const onPlatformChange = (e: Event) => {
|
|
const ce = e as CustomEvent
|
|
platformFilter.value = ce.detail?.platform ?? null
|
|
}
|
|
|
|
onMounted(async () => {
|
|
loadFilters()
|
|
window.addEventListener('hashtag-filter-change', onFilterChange as EventListener)
|
|
window.addEventListener('platform-filter-change', onPlatformChange as EventListener)
|
|
try {
|
|
const res = await fetch(JOURNAL_URL, { cache: 'no-store' })
|
|
if (!res.ok) {
|
|
errored.value = true
|
|
return
|
|
}
|
|
const data = (await res.json()) as JournalPayload
|
|
items.value = Array.isArray(data.items) ? data.items : []
|
|
if (items.value.length === 0) empty.value = true
|
|
} catch (e) {
|
|
errored.value = true
|
|
console.error('JournalList fetch failed', e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('hashtag-filter-change', onFilterChange as EventListener)
|
|
window.removeEventListener('platform-filter-change', onPlatformChange as EventListener)
|
|
})
|
|
|
|
const onItemClick = (item: JournalItem, e: MouseEvent) => {
|
|
if (e.metaKey || e.ctrlKey) {
|
|
window.open(item.url, '_blank', 'noopener')
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
window.dispatchEvent(new CustomEvent('journal-item-click', { detail: { item } }))
|
|
}
|
|
|
|
const visibleItems = computed(() => {
|
|
const keys = Object.keys(filters.value)
|
|
let filtered: JournalItem[]
|
|
if (keys.length === 0) {
|
|
filtered = items.value
|
|
} else {
|
|
const activeKeys = keys.filter((k) => filters.value[k])
|
|
if (activeKeys.length === 0 && keys.some((k) => filters.value[k] === false)) {
|
|
filtered = []
|
|
} else if (activeKeys.length === 0) {
|
|
filtered = items.value
|
|
} else {
|
|
filtered = items.value.filter((it) => activeKeys.includes(it.hashtag))
|
|
}
|
|
}
|
|
if (platformFilter.value) {
|
|
filtered = filtered.filter((it) => it.platform === platformFilter.value)
|
|
}
|
|
return filtered
|
|
})
|
|
|
|
const formatDate = (iso: string) => {
|
|
try {
|
|
const d = new Date(iso)
|
|
if (isNaN(d.getTime())) return ''
|
|
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
const platformLabel = (p: string) => {
|
|
switch (p) {
|
|
case 'gitea':
|
|
case 'github':
|
|
return 'code'
|
|
case 'substack':
|
|
return 'substack'
|
|
case 'instagram':
|
|
return 'insta'
|
|
case 'castopod':
|
|
return 'pod'
|
|
case 'blog':
|
|
return 'blog'
|
|
case 'linkedin':
|
|
return 'linkedin'
|
|
default:
|
|
return p
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-3">
|
|
<p v-if="loading" class="text-neutral-400 italic text-xs">
|
|
Chargement du journal...
|
|
</p>
|
|
<p v-else-if="errored" class="text-neutral-400 italic text-xs">
|
|
Journal en cours d'agregation. Reviens dans quelques heures.
|
|
</p>
|
|
<p v-else-if="empty" class="text-neutral-400 italic text-xs">
|
|
Aucun item agrege. Le cron nocturne tourne a 3h.
|
|
</p>
|
|
<p
|
|
v-else-if="!visibleItems.length"
|
|
class="text-neutral-400 italic text-xs"
|
|
>
|
|
Aucun item ; ajuste les filtres.
|
|
</p>
|
|
<article
|
|
v-for="item in visibleItems"
|
|
:key="item.id"
|
|
class="border-b border-neutral-100 pb-3 last:border-b-0"
|
|
>
|
|
<a
|
|
:href="item.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="block group"
|
|
@click="onItemClick(item, $event)"
|
|
>
|
|
<div class="flex items-baseline gap-2 text-[11px] text-neutral-500 mb-1">
|
|
<time>{{ formatDate(item.date) }}</time>
|
|
<span class="font-mono text-neutral-700">{{ item.hashtag }}</span>
|
|
<span class="text-neutral-400">{{ platformLabel(item.platform) }}</span>
|
|
</div>
|
|
<h4
|
|
class="text-sm text-neutral-900 group-hover:text-neutral-600 leading-snug"
|
|
>
|
|
{{ item.titre }}
|
|
</h4>
|
|
<p
|
|
v-if="item.extrait"
|
|
class="text-xs text-neutral-500 mt-1 line-clamp-2"
|
|
>
|
|
{{ item.extrait }}
|
|
</p>
|
|
<img
|
|
v-if="item.thumbnail"
|
|
:src="item.thumbnail"
|
|
:alt="item.titre"
|
|
loading="lazy"
|
|
class="mt-2 w-full max-h-32 object-cover rounded"
|
|
/>
|
|
</a>
|
|
</article>
|
|
</div>
|
|
</template>
|