Something went wrong. Try again.
A modern frontend for Github repositories (ironically hosted on Tangled)
Something went wrong. Try again.
6.5 kB · 207 lines
Vue
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207<script setup lang="ts">interface GitHubIssue { id: number; number: number; title: string; body?: string | null; state: string; user: { login: string; avatar_url: string; html_url: string; } | null; labels: Array<{ id: number; name: string; color: string; description?: string; }>; assignees: Array<{ login: string; avatar_url: string; html_url: string; }>; comments: number; created_at: string; updated_at: string; html_url: string;}
const props = defineProps<{ issues: GitHubIssue[]; owner: string; repo: string; isLoading?: boolean; currentState?: "open" | "closed" | "all"; totalCount?: number; openCount?: number; closedCount?: number;}>();
const emit = defineEmits<{ 'update:state': [state: "open" | "closed" | "all"]; 'update:sort': [sort: "created" | "updated" | "comments"]; 'update:direction': [direction: "asc" | "desc"];}>();
const currentState = computed(() => props.currentState || "open");const currentSort = ref<"created" | "updated" | "comments">("created");const currentDirection = ref<"asc" | "desc">("desc");
// Filter optionsconst stateOptions = [ { label: 'Open', value: 'open', count: props.openCount }, { label: 'Closed', value: 'closed', count: props.closedCount }, { label: 'All', value: 'all', count: props.totalCount },];
const sortOptions: Array<{ label: string; value: "created" | "updated" | "comments"; direction: "asc" | "desc";}> = [ { label: 'Newest', value: 'created', direction: 'desc' }, { label: 'Oldest', value: 'created', direction: 'asc' }, { label: 'Most commented', value: 'comments', direction: 'desc' }, { label: 'Least commented', value: 'comments', direction: 'asc' }, { label: 'Recently updated', value: 'updated', direction: 'desc' }, { label: 'Least recently updated', value: 'updated', direction: 'asc' },];
function updateState(state: "open" | "closed" | "all") { emit('update:state', state);}
function updateSort(option: { value: "created" | "updated" | "comments", direction: "asc" | "desc" }) { currentSort.value = option.value; currentDirection.value = option.direction; emit('update:sort', option.value); emit('update:direction', option.direction);}</script>
<template> <div class="space-y-4"> <!-- Header with filters and sorting --> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-4 border-b border-gray-200 dark:border-gray-700"> <!-- State filter tabs --> <div class="flex items-center gap-1"> <UButton v-for="option in stateOptions" :key="option.value" :variant="currentState === option.value ? 'solid' : 'ghost'" :color="currentState === option.value ? 'primary' : 'neutral'" size="sm" @click="updateState(option.value as 'open' | 'closed' | 'all')" class="font-medium" > {{ option.label }} <UBadge v-if="option.count !== undefined" :color="currentState === option.value ? 'neutral' : 'neutral'" variant="subtle" size="xs" class="ml-1" > {{ option.count }} </UBadge> </UButton> </div>
<!-- Sort dropdown --> <div class="flex items-center gap-2"> <UDropdown :items="[sortOptions.map(option => ({ label: option.label, click: () => updateSort(option) }))]"> <UButton variant="outline" size="sm" trailing-icon="i-heroicons-chevron-down" > {{ sortOptions.find(opt => opt.value === currentSort && opt.direction === currentDirection )?.label || 'Sort' }} </UButton> </UDropdown> </div> </div>
<!-- Issues list --> <div v-if="isLoading && issues.length === 0" class="space-y-4"> <!-- Loading skeletons --> <div v-for="i in 5" :key="`skeleton-${i}`" class="animate-pulse"> <UCard> <div class="flex items-start gap-3"> <div class="w-5 h-5 bg-gray-300 dark:bg-gray-600 rounded-full flex-shrink-0"></div> <div class="flex-1 space-y-2"> <div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div> <div class="flex items-center gap-4"> <div class="w-4 h-4 bg-gray-300 dark:bg-gray-600 rounded-full"></div> <div class="h-3 bg-gray-300 dark:bg-gray-600 rounded w-24"></div> <div class="h-3 bg-gray-300 dark:bg-gray-600 rounded w-20"></div> </div> </div> </div> </UCard> </div> </div>
<div v-else-if="issues.length === 0" class="text-center py-12"> <UIcon :name="currentState === 'open' ? 'i-heroicons-exclamation-circle' : 'i-heroicons-check-circle'" class="w-12 h-12 mx-auto mb-4 text-gray-400 dark:text-gray-500" /> <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2"> No {{ currentState === 'all' ? '' : currentState }} issues found </h3> <p class="text-gray-600 dark:text-gray-300"> {{ currentState === 'open' ? 'There are no open issues for this repository.' : currentState === 'closed' ? 'There are no closed issues for this repository.' : 'This repository has no issues yet.' }} </p> </div>
<div v-else class="space-y-3"> <IssueCard v-for="issue in issues" :key="issue.id" :issue="issue" :owner="owner" :repo="repo" class="motion-safe:animate-fade-in motion-safe:animate-fill-both" /> </div>
<!-- Loading more indicator --> <div v-if="isLoading && issues.length > 0" class="py-4 flex items-center justify-center"> <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 font-mono text-sm"> <span class="w-4 h-4 border-2 border-gray-300 border-t-gray-600 dark:border-gray-600 dark:border-t-gray-300 rounded-full motion-safe:animate-spin"></span> Loading more issues... </div> </div> </div></template>
<style scoped>@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); }}
.motion-safe\\:animate-fade-in { animation: fade-in 0.3s ease-out;}
.motion-safe\\:animate-fill-both { animation-fill-mode: both;}</style>