trypost/resources/js/components/posts/PostPlatformMetrics.vue
Paulo Castellano cc8f6625a1 feat(channels): Discord post-level engagement metrics
Add per-post Discord metrics (server member count, thread replies as comments,
and reaction counts) read live with the bot token via GET message + guild
with_counts, wired into PostMetricsFetcher. Redesign the shared post metrics
row: monochrome line icons for member/comment counts vs clean reaction pills,
so audience stats read distinctly from reactions. Applies to Telegram too.
2026-06-16 17:05:13 -03:00

163 lines
5.5 KiB
Vue

<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { IconChartBar, IconLoader2, IconMessageCircle, IconUsers } from '@tabler/icons-vue';
import { computed, onMounted, ref } from 'vue';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { formatNumberCompact } from '@/lib/utils';
import { metrics as metricsRoute } from '@/routes/app/posts/platforms';
interface Metric {
label: string;
value: number;
kind?: string;
}
type MetricsResponse = Metric[] | { unsupported: true; reason: string };
interface Props {
postId: string;
postPlatformId: string;
}
const props = defineProps<Props>();
const loading = ref(true);
const metrics = ref<Metric[]>([]);
const stats = computed(() => metrics.value.filter((m) => !m.kind));
const subscribers = computed(() =>
metrics.value.find((m) => m.kind === 'subscribers'),
);
const comments = computed(() =>
metrics.value.find((m) => m.kind === 'comments'),
);
const reactions = computed(() =>
metrics.value.filter((m) => m.kind === 'reaction'),
);
const hasMetrics = computed(() => metrics.value.length > 0);
const http = useHttp<Record<string, never>, MetricsResponse>({});
onMounted(async () => {
try {
const response = await http.get(
metricsRoute.url({
post: props.postId,
postPlatform: props.postPlatformId,
}),
);
if (Array.isArray(response)) {
metrics.value = response;
}
} catch {
// Swallow — empty state hides itself.
} finally {
loading.value = false;
}
});
</script>
<template>
<!-- Loading: subtle inline indicator. -->
<div
v-if="loading"
class="flex items-center gap-2 border-t px-4 py-3 text-xs text-muted-foreground"
>
<IconLoader2 class="h-3 w-3 animate-spin" />
{{ $t('posts.show.metrics_loading') }}
</div>
<!-- Loaded with data: full metrics block. -->
<div v-else-if="hasMetrics" class="border-t px-4 py-3">
<div
class="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase"
>
<IconChartBar class="h-3 w-3" />
{{ $t('posts.show.metrics') }}
</div>
<div v-if="stats.length > 0" class="grid grid-cols-3 gap-2">
<div
v-for="metric in stats"
:key="metric.label"
class="rounded-md bg-muted/50 px-2.5 py-1.5"
>
<p
class="text-[10px] tracking-wider text-muted-foreground uppercase"
>
{{ metric.label }}
</p>
<p class="text-sm font-semibold tabular-nums">
{{ formatNumberCompact(metric.value) }}
</p>
</div>
</div>
<div
v-if="subscribers || comments || reactions.length > 0"
class="flex flex-wrap items-center gap-x-3 gap-y-2"
:class="{ 'mt-2': stats.length > 0 }"
>
<!-- Audience counts: monochrome line icons read as stats, not reactions. -->
<TooltipProvider v-if="subscribers">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
<IconUsers class="size-4" :stroke="1.75" />
<span class="font-semibold tabular-nums text-foreground">{{
formatNumberCompact(subscribers.value)
}}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ subscribers.label }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider v-if="comments">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
<IconMessageCircle class="size-4" :stroke="1.75" />
<span class="font-semibold tabular-nums text-foreground">{{
formatNumberCompact(comments.value)
}}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{{ comments.label }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Divider between audience stats and the reactions chip. -->
<span
v-if="(subscribers || comments) && reactions.length > 0"
class="h-3.5 w-px bg-border"
aria-hidden="true"
/>
<!-- Reactions: each emoji its own clean pill with its count. -->
<span
v-for="reaction in reactions"
:key="reaction.label"
class="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs"
>
<span class="text-[13px] leading-none">{{ reaction.label }}</span>
<span class="font-semibold tabular-nums text-foreground/80">{{
formatNumberCompact(reaction.value)
}}</span>
</span>
</div>
</div>
<!-- No data / unsupported: render nothing so the card stays clean. -->
</template>