A modern frontend for Github repositories (ironically hosted on Tangled)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add very basic issues view

+862 -22
+1 -1
LICENSE
··· 1 1 MIT License 2 2 3 - Copyright (c) 2025 Nuxt UI Templates 3 + Copyright (c) 2025 GitFlux 4 4 5 5 Permission is hereby granted, free of charge, to any person obtaining a copy 6 6 of this software and associated documentation files (the "Software"), to deal
+170
app/components/Issue/Card.vue
··· 1 + <script setup lang="ts"> 2 + interface GitHubIssue { 3 + id: number; 4 + number: number; 5 + title: string; 6 + body?: string | null; 7 + state: string; 8 + user: { 9 + login: string; 10 + avatar_url: string; 11 + html_url: string; 12 + } | null; 13 + labels: Array<{ 14 + id: number; 15 + name: string; 16 + color: string; 17 + description?: string; 18 + }>; 19 + assignees: Array<{ 20 + login: string; 21 + avatar_url: string; 22 + html_url: string; 23 + }>; 24 + comments: number; 25 + created_at: string; 26 + updated_at: string; 27 + html_url: string; 28 + } 29 + 30 + const props = defineProps<{ 31 + issue: GitHubIssue; 32 + owner: string; 33 + repo: string; 34 + }>(); 35 + 36 + // Format time relative to now 37 + const formatTimeAgo = (dateString: string) => { 38 + const date = new Date(dateString); 39 + const now = new Date(); 40 + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 41 + 42 + const intervals = [ 43 + { label: 'year', seconds: 31536000 }, 44 + { label: 'month', seconds: 2592000 }, 45 + { label: 'day', seconds: 86400 }, 46 + { label: 'hour', seconds: 3600 }, 47 + { label: 'minute', seconds: 60 }, 48 + ]; 49 + 50 + for (const interval of intervals) { 51 + const count = Math.floor(diffInSeconds / interval.seconds); 52 + if (count > 0) { 53 + return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`; 54 + } 55 + } 56 + 57 + return 'just now'; 58 + }; 59 + 60 + // Get contrasting text color for label background 61 + const getTextColor = (backgroundColor: string) => { 62 + const hex = backgroundColor.replace('#', ''); 63 + const r = parseInt(hex.substr(0, 2), 16); 64 + const g = parseInt(hex.substr(2, 2), 16); 65 + const b = parseInt(hex.substr(4, 2), 16); 66 + const brightness = (r * 299 + g * 587 + b * 114) / 1000; 67 + return brightness > 128 ? '#000000' : '#ffffff'; 68 + }; 69 + </script> 70 + 71 + <template> 72 + <UCard class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors duration-200"> 73 + <div class="flex items-start gap-3"> 74 + <!-- Issue state icon --> 75 + <div class="flex-shrink-0 mt-1"> 76 + <UIcon 77 + :name="issue.state === 'open' ? 'i-heroicons-exclamation-circle' : 'i-heroicons-check-circle'" 78 + :class="{ 79 + 'text-green-600 dark:text-green-500': issue.state === 'open', 80 + 'text-purple-600 dark:text-purple-500': issue.state === 'closed' 81 + }" 82 + class="w-5 h-5" 83 + /> 84 + </div> 85 + 86 + <div class="flex-1 min-w-0"> 87 + <!-- Issue title and number --> 88 + <div class="flex items-start gap-2 mb-2"> 89 + <NuxtLink 90 + :to="issue.html_url" 91 + target="_blank" 92 + class="font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 line-clamp-2 flex-1 min-w-0" 93 + > 94 + {{ issue.title }} 95 + </NuxtLink> 96 + <span class="text-gray-500 dark:text-gray-400 font-mono text-sm flex-shrink-0"> 97 + #{{ issue.number }} 98 + </span> 99 + </div> 100 + 101 + <!-- Issue metadata --> 102 + <div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mb-3"> 103 + <div v-if="issue.user" class="flex items-center gap-1"> 104 + <UAvatar 105 + :src="issue.user.avatar_url" 106 + :alt="issue.user.login" 107 + size="2xs" 108 + /> 109 + <span>{{ issue.user.login }}</span> 110 + </div> 111 + <div v-else class="flex items-center gap-1"> 112 + <UIcon name="i-heroicons-user" class="w-4 h-4" /> 113 + <span>Unknown user</span> 114 + </div> 115 + <span>opened {{ formatTimeAgo(issue.created_at) }}</span> 116 + <div v-if="issue.comments > 0" class="flex items-center gap-1"> 117 + <UIcon name="i-heroicons-chat-bubble-left" class="w-4 h-4" /> 118 + <span>{{ issue.comments }}</span> 119 + </div> 120 + </div> 121 + 122 + <!-- Labels --> 123 + <div v-if="issue.labels && issue.labels.length > 0" class="flex flex-wrap gap-1 mb-3"> 124 + <span 125 + v-for="label in issue.labels.slice(0, 5)" 126 + :key="label.id" 127 + class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium" 128 + :style="{ 129 + backgroundColor: `#${label.color}`, 130 + color: getTextColor(`#${label.color}`) 131 + }" 132 + > 133 + {{ label.name }} 134 + </span> 135 + <span 136 + v-if="issue.labels.length > 5" 137 + class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200" 138 + > 139 + +{{ issue.labels.length - 5 }} more 140 + </span> 141 + </div> 142 + 143 + <!-- Assignees --> 144 + <div v-if="issue.assignees && issue.assignees.length > 0" class="flex items-center gap-2"> 145 + <span class="text-xs text-gray-500 dark:text-gray-400">Assigned to:</span> 146 + <div class="flex -space-x-2"> 147 + <UTooltip 148 + v-for="assignee in issue.assignees.slice(0, 3)" 149 + :key="assignee.login" 150 + :text="assignee.login" 151 + > 152 + <UAvatar 153 + :src="assignee.avatar_url" 154 + :alt="assignee.login" 155 + size="2xs" 156 + class="border-2 border-white dark:border-gray-800" 157 + /> 158 + </UTooltip> 159 + <div 160 + v-if="issue.assignees.length > 3" 161 + class="flex items-center justify-center w-6 h-6 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-full border-2 border-white dark:border-gray-800" 162 + > 163 + +{{ issue.assignees.length - 3 }} 164 + </div> 165 + </div> 166 + </div> 167 + </div> 168 + </div> 169 + </UCard> 170 + </template>
+207
app/components/Issue/List.vue
··· 1 + <script setup lang="ts"> 2 + interface GitHubIssue { 3 + id: number; 4 + number: number; 5 + title: string; 6 + body?: string | null; 7 + state: string; 8 + user: { 9 + login: string; 10 + avatar_url: string; 11 + html_url: string; 12 + } | null; 13 + labels: Array<{ 14 + id: number; 15 + name: string; 16 + color: string; 17 + description?: string; 18 + }>; 19 + assignees: Array<{ 20 + login: string; 21 + avatar_url: string; 22 + html_url: string; 23 + }>; 24 + comments: number; 25 + created_at: string; 26 + updated_at: string; 27 + html_url: string; 28 + } 29 + 30 + const props = defineProps<{ 31 + issues: GitHubIssue[]; 32 + owner: string; 33 + repo: string; 34 + isLoading?: boolean; 35 + currentState?: "open" | "closed" | "all"; 36 + totalCount?: number; 37 + openCount?: number; 38 + closedCount?: number; 39 + }>(); 40 + 41 + const emit = defineEmits<{ 42 + 'update:state': [state: "open" | "closed" | "all"]; 43 + 'update:sort': [sort: "created" | "updated" | "comments"]; 44 + 'update:direction': [direction: "asc" | "desc"]; 45 + }>(); 46 + 47 + const currentState = computed(() => props.currentState || "open"); 48 + const currentSort = ref<"created" | "updated" | "comments">("created"); 49 + const currentDirection = ref<"asc" | "desc">("desc"); 50 + 51 + // Filter options 52 + const stateOptions = [ 53 + { label: 'Open', value: 'open', count: props.openCount }, 54 + { label: 'Closed', value: 'closed', count: props.closedCount }, 55 + { label: 'All', value: 'all', count: props.totalCount }, 56 + ]; 57 + 58 + const sortOptions: Array<{ 59 + label: string; 60 + value: "created" | "updated" | "comments"; 61 + direction: "asc" | "desc"; 62 + }> = [ 63 + { label: 'Newest', value: 'created', direction: 'desc' }, 64 + { label: 'Oldest', value: 'created', direction: 'asc' }, 65 + { label: 'Most commented', value: 'comments', direction: 'desc' }, 66 + { label: 'Least commented', value: 'comments', direction: 'asc' }, 67 + { label: 'Recently updated', value: 'updated', direction: 'desc' }, 68 + { label: 'Least recently updated', value: 'updated', direction: 'asc' }, 69 + ]; 70 + 71 + function updateState(state: "open" | "closed" | "all") { 72 + emit('update:state', state); 73 + } 74 + 75 + function updateSort(option: { value: "created" | "updated" | "comments", direction: "asc" | "desc" }) { 76 + currentSort.value = option.value; 77 + currentDirection.value = option.direction; 78 + emit('update:sort', option.value); 79 + emit('update:direction', option.direction); 80 + } 81 + </script> 82 + 83 + <template> 84 + <div class="space-y-4"> 85 + <!-- Header with filters and sorting --> 86 + <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"> 87 + <!-- State filter tabs --> 88 + <div class="flex items-center gap-1"> 89 + <UButton 90 + v-for="option in stateOptions" 91 + :key="option.value" 92 + :variant="currentState === option.value ? 'solid' : 'ghost'" 93 + :color="currentState === option.value ? 'primary' : 'neutral'" 94 + size="sm" 95 + @click="updateState(option.value as 'open' | 'closed' | 'all')" 96 + class="font-medium" 97 + > 98 + {{ option.label }} 99 + <UBadge 100 + v-if="option.count !== undefined" 101 + :color="currentState === option.value ? 'neutral' : 'neutral'" 102 + variant="subtle" 103 + size="xs" 104 + class="ml-1" 105 + > 106 + {{ option.count }} 107 + </UBadge> 108 + </UButton> 109 + </div> 110 + 111 + <!-- Sort dropdown --> 112 + <div class="flex items-center gap-2"> 113 + <UDropdown :items="[sortOptions.map(option => ({ 114 + label: option.label, 115 + click: () => updateSort(option) 116 + }))]"> 117 + <UButton 118 + variant="outline" 119 + size="sm" 120 + trailing-icon="i-heroicons-chevron-down" 121 + > 122 + {{ sortOptions.find(opt => 123 + opt.value === currentSort && opt.direction === currentDirection 124 + )?.label || 'Sort' }} 125 + </UButton> 126 + </UDropdown> 127 + </div> 128 + </div> 129 + 130 + <!-- Issues list --> 131 + <div v-if="isLoading && issues.length === 0" class="space-y-4"> 132 + <!-- Loading skeletons --> 133 + <div v-for="i in 5" :key="`skeleton-${i}`" class="animate-pulse"> 134 + <UCard> 135 + <div class="flex items-start gap-3"> 136 + <div class="w-5 h-5 bg-gray-300 dark:bg-gray-600 rounded-full flex-shrink-0"></div> 137 + <div class="flex-1 space-y-2"> 138 + <div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div> 139 + <div class="flex items-center gap-4"> 140 + <div class="w-4 h-4 bg-gray-300 dark:bg-gray-600 rounded-full"></div> 141 + <div class="h-3 bg-gray-300 dark:bg-gray-600 rounded w-24"></div> 142 + <div class="h-3 bg-gray-300 dark:bg-gray-600 rounded w-20"></div> 143 + </div> 144 + </div> 145 + </div> 146 + </UCard> 147 + </div> 148 + </div> 149 + 150 + <div v-else-if="issues.length === 0" class="text-center py-12"> 151 + <UIcon 152 + :name="currentState === 'open' ? 'i-heroicons-exclamation-circle' : 'i-heroicons-check-circle'" 153 + class="w-12 h-12 mx-auto mb-4 text-gray-400 dark:text-gray-500" 154 + /> 155 + <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2"> 156 + No {{ currentState === 'all' ? '' : currentState }} issues found 157 + </h3> 158 + <p class="text-gray-600 dark:text-gray-300"> 159 + {{ currentState === 'open' 160 + ? 'There are no open issues for this repository.' 161 + : currentState === 'closed' 162 + ? 'There are no closed issues for this repository.' 163 + : 'This repository has no issues yet.' }} 164 + </p> 165 + </div> 166 + 167 + <div v-else class="space-y-3"> 168 + <IssueCard 169 + v-for="issue in issues" 170 + :key="issue.id" 171 + :issue="issue" 172 + :owner="owner" 173 + :repo="repo" 174 + class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 175 + /> 176 + </div> 177 + 178 + <!-- Loading more indicator --> 179 + <div v-if="isLoading && issues.length > 0" class="py-4 flex items-center justify-center"> 180 + <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 font-mono text-sm"> 181 + <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> 182 + Loading more issues... 183 + </div> 184 + </div> 185 + </div> 186 + </template> 187 + 188 + <style scoped> 189 + @keyframes fade-in { 190 + from { 191 + opacity: 0; 192 + transform: translateY(10px); 193 + } 194 + to { 195 + opacity: 1; 196 + transform: translateY(0); 197 + } 198 + } 199 + 200 + .motion-safe\\:animate-fade-in { 201 + animation: fade-in 0.3s ease-out; 202 + } 203 + 204 + .motion-safe\\:animate-fill-both { 205 + animation-fill-mode: both; 206 + } 207 + </style>
+27 -8
app/components/Readme.vue
··· 14 14 if (!target) return; 15 15 16 16 // Handle copy button clicks 17 - const copyTarget = target.closest('[data-copy]'); 17 + const copyTarget = target.closest("[data-copy]"); 18 18 if (copyTarget) { 19 - const wrapper = copyTarget.closest('.readme-code-block'); 19 + const wrapper = copyTarget.closest(".readme-code-block"); 20 20 if (!wrapper) return; 21 21 22 - const pre = wrapper.querySelector('pre'); 22 + const pre = wrapper.querySelector("pre"); 23 23 if (!pre?.textContent) return; 24 24 25 25 copy(pre.textContent); 26 26 27 - const icon = copyTarget.querySelector('span'); 27 + const icon = copyTarget.querySelector("span"); 28 28 if (!icon) return; 29 29 30 - const originalIcon = 'i-heroicons-document-duplicate'; 31 - const successIcon = 'i-heroicons-check'; 30 + const originalIcon = "i-heroicons-document-duplicate"; 31 + const successIcon = "i-heroicons-check"; 32 32 33 33 icon.classList.remove(originalIcon); 34 34 icon.classList.add(successIcon); ··· 62 62 min-width: 0; 63 63 } 64 64 65 + @media screen and (prefers-color-scheme: light) { 66 + .readme { 67 + color: #000; 68 + } 69 + 70 + .readme svg { 71 + filter: invert(1); 72 + } 73 + } 74 + 75 + @media screen and (prefers-color-scheme: dark) { 76 + .readme { 77 + color: #fff; 78 + } 79 + 80 + .readme svg { 81 + filter: invert(1); 82 + } 83 + } 84 + 65 85 /* README headings */ 66 86 .readme :deep(h1), 67 87 .readme :deep(h2), ··· 71 91 .readme :deep(h6) { 72 92 font-family: "Monaspace Krypton", ui-monospace, SFMono-Regular, monospace; 73 93 font-weight: 500; 74 - color: rgb(17 24 39); 75 94 margin-top: 2rem; 76 95 margin-bottom: 1rem; 77 96 line-height: 1.3; ··· 260 279 border-top-color: rgb(75 85 99); 261 280 } 262 281 } 263 - </style> 282 + </style>
+69 -2
app/composables/useGithub.ts
··· 3 3 const useOctokit = () => { 4 4 const { user } = useUserSession(); 5 5 6 - if (!user.value?.githubToken) { 6 + if (!(user.value as any)?.githubToken) { 7 7 throw new Error("GitHub access token not found. Please login first."); 8 8 } 9 9 10 - return new Octokit({ auth: user.value.githubToken }); 10 + return new Octokit({ auth: (user.value as any).githubToken }); 11 11 }; 12 12 13 13 export const useAuthenticatedUser = async (token: string) => { ··· 99 99 {}, 100 100 ); 101 101 }; 102 + 103 + export const useGithubRepoIssues = ( 104 + owner: string, 105 + repo: string, 106 + options: MaybeRef<{ 107 + state?: "open" | "closed" | "all"; 108 + sort?: "created" | "updated" | "comments"; 109 + direction?: "asc" | "desc"; 110 + per_page?: number; 111 + page?: number; 112 + }> = {}, 113 + ) => { 114 + return useAsyncData( 115 + () => `github-issues-${owner}-${repo}-${JSON.stringify(unref(options))}`, 116 + async () => { 117 + const octokit = useOctokit(); 118 + const opts = unref(options); 119 + return octokit.rest.issues.listForRepo({ 120 + owner, 121 + repo, 122 + state: opts.state || "open", 123 + sort: opts.sort || "created", 124 + direction: opts.direction || "desc", 125 + per_page: opts.per_page || 25, 126 + page: opts.page || 1, 127 + }); 128 + }, 129 + { 130 + watch: [() => unref(options)], 131 + }, 132 + ); 133 + }; 134 + 135 + export const useGithubRepoIssuesCount = (owner: string, repo: string, state: "closed") => { 136 + return useAsyncData( 137 + `github-issues-count-${owner}-${repo}-${state}`, 138 + async () => { 139 + const octokit = useOctokit(); 140 + try { 141 + // Use the search API to get an accurate count of closed issues 142 + // This is more efficient than fetching all issues just for the count 143 + const searchQuery = `repo:${owner}/${repo} type:issue state:${state}`; 144 + const response = await octokit.rest.search.issuesAndPullRequests({ 145 + q: searchQuery, 146 + per_page: 1, // We only need the total_count, not the actual results 147 + }); 148 + return response.data.total_count; 149 + } catch { 150 + // Fallback: try to fetch first page of closed issues and estimate 151 + try { 152 + const response = await octokit.rest.issues.listForRepo({ 153 + owner, 154 + repo, 155 + state, 156 + per_page: 1, 157 + }); 158 + // This won't give us the exact count, but it's better than nothing 159 + // GitHub doesn't provide total count in the response headers for issues API 160 + return response.data.length > 0 ? 1 : 0; 161 + } catch { 162 + return 0; 163 + } 164 + } 165 + }, 166 + {}, 167 + ); 168 + };
+40 -10
app/pages/[org]/[repo].vue app/pages/[org]/[repo]/index.vue
··· 136 136 </script> 137 137 138 138 <template> 139 - <main class="container flex-1 w-full py-8"> 139 + <div class="flex-1 w-full py-8"> 140 140 <!-- Loading state --> 141 141 <div v-if="pending" class="animate-pulse"> 142 142 <div class="h-8 bg-gray-300 rounded w-2/3 mb-4"></div> ··· 212 212 <!-- Navigation buttons --> 213 213 <nav class="hidden sm:flex items-center gap-1 ml-auto"> 214 214 <UButton 215 + :to="`/${org}/${repo}/issues`" 216 + icon="i-heroicons-exclamation-circle" 217 + size="sm" 218 + variant="outline" 219 + > 220 + Issues 221 + <UBadge 222 + v-if="repository?.open_issues_count" 223 + color="primary" 224 + variant="subtle" 225 + size="xs" 226 + class="ml-1" 227 + > 228 + {{ repository.open_issues_count }} 229 + </UBadge> 230 + </UButton> 231 + <UButton 215 232 :to="repository.html_url" 216 233 target="_blank" 217 234 icon="i-simple-icons-github" ··· 254 271 Website 255 272 </UButton> 256 273 257 - <!-- Mobile GitHub link --> 274 + <!-- Mobile navigation links --> 275 + <UButton 276 + :to="`/${org}/${repo}/issues`" 277 + icon="i-heroicons-exclamation-circle" 278 + size="sm" 279 + variant="ghost" 280 + class="sm:hidden text-gray-600 dark:text-gray-300" 281 + > 282 + Issues 283 + <UBadge 284 + v-if="repository?.open_issues_count" 285 + color="primary" 286 + variant="subtle" 287 + size="xs" 288 + class="ml-1" 289 + > 290 + {{ repository.open_issues_count }} 291 + </UBadge> 292 + </UButton> 293 + 258 294 <UButton 259 295 :to="repository.html_url" 260 296 target="_blank" ··· 376 412 </a> 377 413 </h2> 378 414 </div> 379 - 380 - <div 381 - v-if="readmeData?.html" 382 - class="prose prose-gray dark:prose-invert max-w-none" 383 - > 384 - <div v-html="readmeData.html" /> 385 - </div> 415 + <Readme v-if="readmeData?.html" :html="readmeData?.html"></Readme> 386 416 <div v-else class="text-center py-12 text-gray-500 dark:text-gray-400"> 387 417 <UIcon 388 418 name="i-heroicons-document-text" ··· 609 639 </p> 610 640 <UButton to="/" icon="i-heroicons-home"> Back to Home </UButton> 611 641 </div> 612 - </main> 642 + </div> 613 643 </template> 614 644 615 645 <style scoped>
+347
app/pages/[org]/[repo]/issues.vue
··· 1 + <script setup lang="ts"> 2 + const route = useRoute(); 3 + 4 + const org = route.params.org as string; 5 + const repo = route.params.repo as string; 6 + 7 + // State management from URL query parameters 8 + const currentState = computed({ 9 + get: () => { 10 + const state = route.query.state as string; 11 + return (state === "closed" || state === "all") ? state : "open"; 12 + }, 13 + set: (value: "open" | "closed" | "all") => { 14 + navigateTo({ 15 + query: { ...route.query, state: value } 16 + }); 17 + } 18 + }); 19 + 20 + const currentSort = computed({ 21 + get: () => { 22 + const sort = route.query.sort as string; 23 + return (sort === "updated" || sort === "comments") ? sort : "created"; 24 + }, 25 + set: (value: "created" | "updated" | "comments") => { 26 + navigateTo({ 27 + query: { ...route.query, sort: value } 28 + }); 29 + } 30 + }); 31 + 32 + const currentDirection = computed({ 33 + get: () => { 34 + const direction = route.query.direction as string; 35 + return direction === "asc" ? "asc" : "desc"; 36 + }, 37 + set: (value: "asc" | "desc") => { 38 + navigateTo({ 39 + query: { ...route.query, direction: value } 40 + }); 41 + } 42 + }); 43 + 44 + // Fetch repository data for context 45 + const { data: repoData } = useGithubRepo(org, repo); 46 + const repository = computed(() => repoData.value?.data); 47 + 48 + // Fetch issues with current filters - reactive to URL changes 49 + const issuesOptions = computed(() => ({ 50 + state: currentState.value as "open" | "closed" | "all", 51 + sort: currentSort.value as "created" | "updated" | "comments", 52 + direction: currentDirection.value as "asc" | "desc", 53 + per_page: 25, 54 + })); 55 + 56 + const { 57 + data: issuesData, 58 + pending: issuesLoading, 59 + refresh: refreshIssues, 60 + } = useGithubRepoIssues(org, repo, issuesOptions); 61 + 62 + const issues = computed(() => (issuesData.value?.data || []) as any[]); 63 + 64 + // Fetch accurate closed issues count using the search API 65 + const { data: closedIssuesCount } = useGithubRepoIssuesCount(org, repo, "closed"); 66 + 67 + // Calculate counts 68 + const openCount = computed(() => { 69 + // GitHub API doesn't return total count in headers for issues, so we'll use the repo's open_issues_count 70 + return repository.value?.open_issues_count || 0; 71 + }); 72 + 73 + const closedCount = computed(() => { 74 + return closedIssuesCount.value || 0; 75 + }); 76 + 77 + const totalCount = computed(() => { 78 + const open = openCount.value || 0; 79 + const closed = closedCount.value || 0; 80 + return open + closed; 81 + }); 82 + 83 + // Header reference for sticky behavior 84 + const header = useTemplateRef("header"); 85 + const isHeaderPinned = shallowRef(false); 86 + 87 + function checkHeaderPosition() { 88 + const el = header.value; 89 + if (!el) return; 90 + 91 + const style = getComputedStyle(el); 92 + const top = parseFloat(style.top) || 0; 93 + const rect = el.getBoundingClientRect(); 94 + 95 + isHeaderPinned.value = Math.abs(rect.top - top) < 1; 96 + } 97 + 98 + if (import.meta.client) { 99 + useEventListener("scroll", checkHeaderPosition, { passive: true }); 100 + useEventListener("resize", checkHeaderPosition); 101 + } 102 + 103 + onMounted(() => { 104 + checkHeaderPosition(); 105 + }); 106 + 107 + // Handle filter and sort updates 108 + function updateState(state: "open" | "closed" | "all") { 109 + currentState.value = state; 110 + } 111 + 112 + function updateSort(sort: "created" | "updated" | "comments") { 113 + currentSort.value = sort; 114 + } 115 + 116 + function updateDirection(direction: "asc" | "desc") { 117 + currentDirection.value = direction; 118 + } 119 + 120 + // SEO meta 121 + useSeoMeta({ 122 + title: () => 123 + repository.value 124 + ? `Issues · ${repository.value.full_name} - Gitflux` 125 + : `Issues · ${org}/${repo} - Gitflux`, 126 + description: () => 127 + repository.value?.description 128 + ? `View and track issues for ${repository.value.full_name}. ${repository.value.description}` 129 + : `View and track issues for ${org}/${repo} repository on Gitflux`, 130 + }); 131 + </script> 132 + 133 + <template> 134 + <div class="flex-1 w-full py-8"> 135 + <article class="issues-page"> 136 + <!-- Repository header --> 137 + <header 138 + class="area-header sticky top-14 z-10 bg-white dark:bg-gray-900 py-4 border-b border-gray-200 dark:border-gray-700" 139 + ref="header" 140 + :class="{ 'shadow-sm': isHeaderPinned }" 141 + > 142 + <div class="flex items-center gap-3 flex-wrap min-w-0"> 143 + <div class="flex flex-col items-start min-w-0"> 144 + <nav 145 + class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-1" 146 + > 147 + <NuxtLink 148 + :to="`/${org}`" 149 + class="hover:text-gray-900 dark:hover:text-white transition-colors duration-200" 150 + > 151 + {{ org }} 152 + </NuxtLink> 153 + <span>/</span> 154 + <NuxtLink 155 + :to="`/${org}/${repo}`" 156 + class="hover:text-gray-900 dark:hover:text-white transition-colors duration-200" 157 + > 158 + {{ repo }} 159 + </NuxtLink> 160 + <span>/</span> 161 + <span class="text-gray-900 dark:text-white font-medium" 162 + >Issues</span 163 + > 164 + </nav> 165 + 166 + <h1 167 + class="font-mono text-2xl sm:text-3xl font-medium text-gray-900 dark:text-white flex items-center gap-2" 168 + > 169 + <UIcon 170 + name="i-heroicons-exclamation-circle" 171 + class="w-6 h-6 text-green-600 dark:text-green-500" 172 + /> 173 + Issues 174 + </h1> 175 + </div> 176 + 177 + <!-- Navigation buttons --> 178 + <nav class="hidden sm:flex items-center gap-1 ml-auto"> 179 + <UButton 180 + :to="`/${org}/${repo}`" 181 + icon="i-heroicons-arrow-left" 182 + size="sm" 183 + variant="outline" 184 + > 185 + Back to Repository 186 + </UButton> 187 + </nav> 188 + </div> 189 + </header> 190 + 191 + <!-- Issues content --> 192 + <section class="area-content"> 193 + <div class="mb-6"> 194 + <div v-if="repository" class="max-w-3xl min-h-[2rem] mb-4"> 195 + <p 196 + v-if="repository.description" 197 + class="text-gray-600 dark:text-gray-300" 198 + > 199 + {{ repository.description }} 200 + </p> 201 + </div> 202 + </div> 203 + 204 + <IssueList 205 + :issues="issues" 206 + :owner="org" 207 + :repo="repo" 208 + :is-loading="issuesLoading" 209 + :current-state="currentState" 210 + :total-count="totalCount" 211 + :open-count="openCount" 212 + :closed-count="closedCount" 213 + @update:state="updateState" 214 + @update:sort="updateSort" 215 + @update:direction="updateDirection" 216 + /> 217 + </section> 218 + 219 + <!-- Sidebar --> 220 + <div class="area-sidebar"> 221 + <div class="sticky top-24 space-y-6"> 222 + <!-- Repository info card --> 223 + <UCard v-if="repository"> 224 + <template #header> 225 + <h3 class="text-sm font-medium text-gray-900 dark:text-white"> 226 + Repository 227 + </h3> 228 + </template> 229 + 230 + <div class="space-y-3"> 231 + <div class="flex items-center gap-2"> 232 + <UIcon 233 + :name=" 234 + repository.private 235 + ? 'i-heroicons-lock-closed' 236 + : 'i-heroicons-globe-alt' 237 + " 238 + :class="{ 239 + 'text-amber-500': repository.private, 240 + 'text-green-500': !repository.private, 241 + }" 242 + class="w-4 h-4" 243 + /> 244 + <span class="text-sm text-gray-900 dark:text-white"> 245 + {{ repository.private ? "Private" : "Public" }} 246 + </span> 247 + </div> 248 + 249 + <div class="flex items-center gap-2"> 250 + <UIcon 251 + name="i-heroicons-star" 252 + class="w-4 h-4 text-yellow-500" 253 + /> 254 + <span class="text-sm text-gray-900 dark:text-white"> 255 + {{ repository.stargazers_count?.toLocaleString() || 0 }} stars 256 + </span> 257 + </div> 258 + 259 + <div class="flex items-center gap-2"> 260 + <UIcon 261 + name="i-heroicons-code-bracket" 262 + class="w-4 h-4 text-blue-500" 263 + /> 264 + <span class="text-sm text-gray-900 dark:text-white"> 265 + {{ repository.forks_count?.toLocaleString() || 0 }} forks 266 + </span> 267 + </div> 268 + 269 + <div class="pt-3 border-t border-gray-200 dark:border-gray-700"> 270 + <UButton 271 + :to="repository.html_url" 272 + target="_blank" 273 + icon="i-simple-icons-github" 274 + size="sm" 275 + variant="outline" 276 + block 277 + > 278 + View on GitHub 279 + </UButton> 280 + </div> 281 + </div> 282 + </UCard> 283 + </div> 284 + </div> 285 + </article> 286 + </div> 287 + </template> 288 + 289 + <style scoped> 290 + .issues-page { 291 + display: grid; 292 + gap: 2rem; 293 + padding-inline: 2rem; 294 + 295 + /* Mobile: single column layout */ 296 + grid-template-columns: minmax(0, 1fr); 297 + grid-template-areas: 298 + "header" 299 + "content" 300 + "sidebar"; 301 + } 302 + 303 + /* Tablet: header full width, content and sidebar side by side */ 304 + @media (min-width: 1024px) { 305 + .issues-page { 306 + grid-template-columns: 2fr 1fr; 307 + grid-template-areas: 308 + "header header" 309 + "content sidebar"; 310 + } 311 + } 312 + 313 + /* Desktop: floating sidebar alongside all content */ 314 + @media (min-width: 1280px) { 315 + .issues-page { 316 + grid-template-columns: 1fr 20rem; 317 + grid-template-areas: 318 + "header sidebar" 319 + "content sidebar"; 320 + } 321 + } 322 + 323 + .area-header { 324 + grid-area: header; 325 + } 326 + 327 + .area-content { 328 + grid-area: content; 329 + min-width: 0; 330 + } 331 + 332 + .area-sidebar { 333 + grid-area: sidebar; 334 + } 335 + 336 + /* Ensure proper text wrapping */ 337 + .issues-page { 338 + word-wrap: break-word; 339 + overflow-wrap: break-word; 340 + max-width: 100%; 341 + } 342 + 343 + .issues-page > * { 344 + max-width: 100%; 345 + min-width: 0; 346 + } 347 + </style>
+1 -1
nuxt.config.ts
··· 21 21 }, 22 22 session: { 23 23 name: "nuxt-session", 24 - password: process.env.NUXT_SESSION_PASSWORD || "", 24 + password: "", 25 25 cookie: { 26 26 sameSite: "lax", 27 27 secure: false, // Allow cookies over HTTP in development