a love letter to tangled (android, iOS, and a search API)

feat: components and pages (placeholders)

+2144 -242
+3
README.md
··· 1 + # Twisted 2 + 3 + A mobile client for [Tangled](https://tangled.org).
+17 -17
docs/tasks/phase-1.md
··· 35 35 36 36 ## Design System Components 37 37 38 - - [ ] `components/common/RepoCard.vue` — compact repo summary (name, owner, description, language, stars) 39 - - [ ] `components/common/UserCard.vue` — avatar + handle + bio snippet 40 - - [ ] `components/common/ActivityCard.vue` — icon + actor + verb + target + relative timestamp 41 - - [ ] `components/common/EmptyState.vue` — icon + message + optional action button 42 - - [ ] `components/common/ErrorBoundary.vue` — catch errors, show retry UI 43 - - [ ] `components/common/SkeletonLoader.vue` — shimmer placeholders (variants: card, list-item, profile) 44 - - [ ] `components/repo/FileTreeItem.vue` — file/dir icon + name 45 - - [ ] `components/repo/MarkdownRenderer.vue` — render markdown to HTML (stub with basic styling) 38 + - [x] `components/common/RepoCard.vue` — compact repo summary (name, owner, description, language, stars) 39 + - [x] `components/common/UserCard.vue` — avatar + handle + bio snippet 40 + - [x] `components/common/ActivityCard.vue` — icon + actor + verb + target + relative timestamp 41 + - [x] `components/common/EmptyState.vue` — icon + message + optional action button 42 + - [x] `components/common/ErrorBoundary.vue` — catch errors, show retry UI 43 + - [x] `components/common/SkeletonLoader.vue` — shimmer placeholders (variants: card, list-item, profile) 44 + - [x] `components/repo/FileTreeItem.vue` — file/dir icon + name 45 + - [x] `components/repo/MarkdownRenderer.vue` — render markdown to HTML (stub with basic styling) 46 46 47 47 ## Feature Pages (placeholder with mock data) 48 48 49 - - [ ] `features/home/HomePage.vue` — trending repos list, recent activity list 50 - - [ ] `features/explore/ExplorePage.vue` — search bar (non-functional), repo/user tabs, repo list 51 - - [ ] `features/repo/RepoDetailPage.vue` — segmented layout: Overview, Files, Issues, PRs 52 - - [ ] `features/repo/RepoOverview.vue` — header, description, README placeholder, stats 53 - - [ ] `features/repo/RepoFiles.vue` — file tree list from mock data 54 - - [ ] `features/repo/RepoIssues.vue` — issue list from mock data 55 - - [ ] `features/repo/RepoPRs.vue` — PR list from mock data 56 - - [ ] `features/activity/ActivityPage.vue` — filter chips + activity card list 57 - - [ ] `features/profile/ProfilePage.vue` — sign-in prompt (unauthenticated state) 49 + - [x] `features/home/HomePage.vue` — trending repos list, recent activity list 50 + - [x] `features/explore/ExplorePage.vue` — search bar (non-functional), repo/user tabs, repo list 51 + - [x] `features/repo/RepoDetailPage.vue` — segmented layout: Overview, Files, Issues, PRs 52 + - [x] `features/repo/RepoOverview.vue` — header, description, README placeholder, stats 53 + - [x] `features/repo/RepoFiles.vue` — file tree list from mock data 54 + - [x] `features/repo/RepoIssues.vue` — issue list from mock data 55 + - [x] `features/repo/RepoPRs.vue` — PR list from mock data 56 + - [x] `features/activity/ActivityPage.vue` — filter chips + activity card list 57 + - [x] `features/profile/ProfilePage.vue` — sign-in prompt (unauthenticated state) 58 58 59 59 ## Quality 60 60
+14 -64
src/app/router/index.ts
··· 1 - import { createRouter, createWebHistory } from '@ionic/vue-router'; 2 - import type { RouteRecordRaw } from 'vue-router'; 1 + import { createRouter, createWebHistory } from "@ionic/vue-router"; 2 + import type { RouteRecordRaw } from "vue-router"; 3 3 4 - import TabsPage from '@/views/TabsPage.vue'; 4 + import TabsPage from "@/views/TabsPage.vue"; 5 5 6 6 const routes: RouteRecordRaw[] = [ 7 - { 8 - path: '/', 9 - redirect: '/tabs/home', 10 - }, 7 + { path: "/", redirect: "/tabs/home" }, 11 8 { 12 - path: '/tabs/', 9 + path: "/tabs/", 13 10 component: TabsPage, 14 11 children: [ 15 - { 16 - path: '', 17 - redirect: '/tabs/home', 18 - }, 19 - { 20 - path: 'home', 21 - children: [ 22 - { 23 - path: '', 24 - component: () => import('@/features/home/HomePage.vue'), 25 - }, 26 - { 27 - path: 'repo/:owner/:repo', 28 - component: () => import('@/features/repo/RepoDetailPage.vue'), 29 - }, 30 - ], 31 - }, 32 - { 33 - path: 'explore', 34 - children: [ 35 - { 36 - path: '', 37 - component: () => import('@/features/explore/ExplorePage.vue'), 38 - }, 39 - { 40 - path: 'repo/:owner/:repo', 41 - component: () => import('@/features/repo/RepoDetailPage.vue'), 42 - }, 43 - ], 44 - }, 45 - { 46 - path: 'activity', 47 - children: [ 48 - { 49 - path: '', 50 - component: () => import('@/features/activity/ActivityPage.vue'), 51 - }, 52 - { 53 - path: 'repo/:owner/:repo', 54 - component: () => import('@/features/repo/RepoDetailPage.vue'), 55 - }, 56 - ], 57 - }, 58 - { 59 - path: 'profile', 60 - children: [ 61 - { 62 - path: '', 63 - component: () => import('@/features/profile/ProfilePage.vue'), 64 - }, 65 - ], 66 - }, 12 + { path: "", redirect: "/tabs/home" }, 13 + { path: "home", component: () => import("@/features/home/HomePage.vue") }, 14 + { path: "home/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 15 + { path: "explore", component: () => import("@/features/explore/ExplorePage.vue") }, 16 + { path: "explore/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 17 + { path: "activity", component: () => import("@/features/activity/ActivityPage.vue") }, 18 + { path: "activity/repo/:owner/:repo", component: () => import("@/features/repo/RepoDetailPage.vue") }, 19 + { path: "profile", component: () => import("@/features/profile/ProfilePage.vue") }, 67 20 ], 68 21 }, 69 22 ]; 70 23 71 - const router = createRouter({ 72 - history: createWebHistory(import.meta.env.BASE_URL), 73 - routes, 74 - }); 24 + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }); 75 25 76 26 export default router;
+131
src/components/common/ActivityCard.vue
··· 1 + <template> 2 + <ion-item class="activity-item" lines="none" button @click="emit('click')"> 3 + <div slot="start" class="kind-icon" :style="{ color: config.color, background: config.dimColor }"> 4 + <ion-icon :icon="config.icon" /> 5 + </div> 6 + 7 + <ion-label class="activity-label"> 8 + <div class="activity-text"> 9 + <span class="actor">{{ item.actorHandle }}</span> 10 + <span class="verb"> {{ config.verb }} </span> 11 + <span v-if="item.targetName" class="target">{{ item.targetName }}</span> 12 + </div> 13 + <div class="activity-time">{{ relativeTime(item.createdAt) }}</div> 14 + </ion-label> 15 + </ion-item> 16 + </template> 17 + 18 + <script setup lang="ts"> 19 + import { computed } from "vue"; 20 + import { IonItem, IonLabel, IonIcon } from "@ionic/vue"; 21 + import { 22 + addCircleOutline, 23 + starOutline, 24 + personAddOutline, 25 + gitMergeOutline, 26 + checkmarkCircleOutline, 27 + alertCircleOutline, 28 + closeCircleOutline, 29 + } from "ionicons/icons"; 30 + import type { ActivityItem } from "@/domain/models/activity"; 31 + 32 + const props = defineProps<{ item: ActivityItem }>(); 33 + const emit = defineEmits<{ click: [] }>(); 34 + 35 + type KindConfig = { icon: string; color: string; dimColor: string; verb: string }; 36 + 37 + const KIND_MAP: Record<ActivityItem["kind"], KindConfig> = { 38 + repo_created: { icon: addCircleOutline, color: "#22d3ee", dimColor: "rgba(34,211,238,0.1)", verb: "created" }, 39 + repo_starred: { icon: starOutline, color: "#fbbf24", dimColor: "rgba(251,191,36,0.1)", verb: "starred" }, 40 + user_followed: { icon: personAddOutline, color: "#a78bfa", dimColor: "rgba(167,139,250,0.1)", verb: "followed" }, 41 + pr_opened: { icon: gitMergeOutline, color: "#22d3ee", dimColor: "rgba(34,211,238,0.1)", verb: "opened a PR on" }, 42 + pr_merged: { 43 + icon: checkmarkCircleOutline, 44 + color: "#34d399", 45 + dimColor: "rgba(52,211,153,0.1)", 46 + verb: "merged a PR in", 47 + }, 48 + issue_opened: { 49 + icon: alertCircleOutline, 50 + color: "#fb923c", 51 + dimColor: "rgba(251,146,60,0.1)", 52 + verb: "opened an issue on", 53 + }, 54 + issue_closed: { 55 + icon: closeCircleOutline, 56 + color: "#6b7280", 57 + dimColor: "rgba(107,114,128,0.1)", 58 + verb: "closed an issue on", 59 + }, 60 + }; 61 + 62 + const config = computed(() => KIND_MAP[props.item.kind]); 63 + 64 + function relativeTime(iso: string): string { 65 + const diff = Date.now() - new Date(iso).getTime(); 66 + const m = Math.floor(diff / 60000); 67 + const h = Math.floor(m / 60); 68 + const d = Math.floor(h / 24); 69 + if (d > 0) return `${d}d ago`; 70 + if (h > 0) return `${h}h ago`; 71 + if (m > 0) return `${m}m ago`; 72 + return "just now"; 73 + } 74 + </script> 75 + 76 + <style scoped> 77 + .activity-item { 78 + --background: transparent; 79 + --padding-start: 16px; 80 + --padding-end: 16px; 81 + --inner-padding-end: 0; 82 + --min-height: 56px; 83 + } 84 + 85 + .kind-icon { 86 + display: flex; 87 + align-items: center; 88 + justify-content: center; 89 + width: 34px; 90 + height: 34px; 91 + border-radius: 50%; 92 + font-size: 17px; 93 + margin-right: 12px; 94 + flex-shrink: 0; 95 + } 96 + 97 + .activity-label { 98 + padding: 10px 0; 99 + white-space: normal; 100 + } 101 + 102 + .activity-text { 103 + font-size: 13px; 104 + line-height: 1.45; 105 + color: var(--t-text-secondary); 106 + } 107 + 108 + .actor { 109 + font-family: var(--t-mono); 110 + font-size: 12px; 111 + font-weight: 600; 112 + color: var(--t-accent); 113 + } 114 + 115 + .verb { 116 + color: var(--t-text-secondary); 117 + } 118 + 119 + .target { 120 + font-family: var(--t-mono); 121 + font-size: 12px; 122 + font-weight: 500; 123 + color: var(--t-text-primary); 124 + } 125 + 126 + .activity-time { 127 + font-size: 11px; 128 + color: var(--t-text-muted); 129 + margin-top: 2px; 130 + } 131 + </style>
+70
src/components/common/EmptyState.vue
··· 1 + <template> 2 + <div class="empty-state"> 3 + <div class="icon-wrap"> 4 + <ion-icon :icon="icon" class="empty-icon" /> 5 + </div> 6 + <h3 class="empty-title">{{ title }}</h3> 7 + <p v-if="message" class="empty-message">{{ message }}</p> 8 + <ion-button v-if="actionLabel" class="empty-action" fill="outline" size="small" @click="emit('action')"> 9 + {{ actionLabel }} 10 + </ion-button> 11 + </div> 12 + </template> 13 + 14 + <script setup lang="ts"> 15 + import { IonIcon, IonButton } from "@ionic/vue"; 16 + 17 + defineProps<{ icon: string; title: string; message?: string; actionLabel?: string }>(); 18 + 19 + const emit = defineEmits<{ action: [] }>(); 20 + </script> 21 + 22 + <style scoped> 23 + .empty-state { 24 + display: flex; 25 + flex-direction: column; 26 + align-items: center; 27 + justify-content: center; 28 + padding: 48px 32px; 29 + text-align: center; 30 + gap: 10px; 31 + } 32 + 33 + .icon-wrap { 34 + width: 64px; 35 + height: 64px; 36 + border-radius: 50%; 37 + background: var(--t-accent-dim); 38 + display: flex; 39 + align-items: center; 40 + justify-content: center; 41 + margin-bottom: 4px; 42 + } 43 + 44 + .empty-icon { 45 + font-size: 28px; 46 + color: var(--t-accent); 47 + } 48 + 49 + .empty-title { 50 + font-size: 16px; 51 + font-weight: 600; 52 + color: var(--t-text-primary); 53 + margin: 0; 54 + line-height: 1.3; 55 + } 56 + 57 + .empty-message { 58 + font-size: 13px; 59 + color: var(--t-text-secondary); 60 + margin: 0; 61 + line-height: 1.5; 62 + max-width: 260px; 63 + } 64 + 65 + .empty-action { 66 + --color: var(--t-accent); 67 + --border-color: var(--t-accent); 68 + margin-top: 6px; 69 + } 70 + </style>
+85
src/components/common/ErrorBoundary.vue
··· 1 + <template> 2 + <template v-if="error"> 3 + <div class="error-state"> 4 + <div class="error-icon-wrap"> 5 + <ion-icon :icon="alertCircleOutline" class="error-icon" /> 6 + </div> 7 + <h3 class="error-title">Something went wrong</h3> 8 + <p class="error-message">{{ error.message }}</p> 9 + <ion-button class="retry-btn" fill="outline" size="small" @click="retry"> 10 + <ion-icon slot="start" :icon="refreshOutline" /> 11 + Try again 12 + </ion-button> 13 + </div> 14 + </template> 15 + <template v-else> 16 + <slot /> 17 + </template> 18 + </template> 19 + 20 + <script setup lang="ts"> 21 + import { ref, onErrorCaptured } from "vue"; 22 + import { IonIcon, IonButton } from "@ionic/vue"; 23 + import { alertCircleOutline, refreshOutline } from "ionicons/icons"; 24 + 25 + const error = ref<Error | null>(null); 26 + 27 + onErrorCaptured((err) => { 28 + error.value = err instanceof Error ? err : new Error(String(err)); 29 + return false; 30 + }); 31 + 32 + function retry() { 33 + error.value = null; 34 + } 35 + </script> 36 + 37 + <style scoped> 38 + .error-state { 39 + display: flex; 40 + flex-direction: column; 41 + align-items: center; 42 + justify-content: center; 43 + padding: 48px 32px; 44 + text-align: center; 45 + gap: 10px; 46 + } 47 + 48 + .error-icon-wrap { 49 + width: 64px; 50 + height: 64px; 51 + border-radius: 50%; 52 + background: var(--t-red-dim); 53 + display: flex; 54 + align-items: center; 55 + justify-content: center; 56 + margin-bottom: 4px; 57 + } 58 + 59 + .error-icon { 60 + font-size: 28px; 61 + color: var(--t-red); 62 + } 63 + 64 + .error-title { 65 + font-size: 16px; 66 + font-weight: 600; 67 + color: var(--t-text-primary); 68 + margin: 0; 69 + } 70 + 71 + .error-message { 72 + font-size: 13px; 73 + color: var(--t-text-secondary); 74 + margin: 0; 75 + line-height: 1.5; 76 + max-width: 260px; 77 + font-family: var(--t-mono); 78 + } 79 + 80 + .retry-btn { 81 + --color: var(--t-red); 82 + --border-color: var(--t-red); 83 + margin-top: 6px; 84 + } 85 + </style>
+169
src/components/common/RepoCard.vue
··· 1 + <template> 2 + <ion-card class="repo-card" button @click="emit('click')"> 3 + <ion-card-content class="card-body"> 4 + <div class="repo-header"> 5 + <span class="repo-owner">{{ repo.ownerHandle }}/</span><span class="repo-name">{{ repo.name }}</span> 6 + <div v-if="repo.stars != null" class="stars"> 7 + <ion-icon :icon="starOutline" class="star-icon" /> 8 + <span class="star-count">{{ formatCount(repo.stars) }}</span> 9 + </div> 10 + </div> 11 + 12 + <p v-if="repo.description" class="repo-description">{{ repo.description }}</p> 13 + 14 + <div class="repo-meta"> 15 + <span v-if="repo.primaryLanguage" class="lang-badge"> 16 + <span class="lang-dot" :style="{ background: langColor(repo.primaryLanguage) }" /> 17 + {{ repo.primaryLanguage }} 18 + </span> 19 + <span v-if="repo.updatedAt" class="meta-dot">·</span> 20 + <span v-if="repo.updatedAt" class="updated-at">{{ relativeTime(repo.updatedAt) }}</span> 21 + </div> 22 + </ion-card-content> 23 + </ion-card> 24 + </template> 25 + 26 + <script setup lang="ts"> 27 + import { IonCard, IonCardContent, IonIcon } from "@ionic/vue"; 28 + import { starOutline } from "ionicons/icons"; 29 + import type { RepoSummary } from "@/domain/models/repo"; 30 + 31 + defineProps<{ repo: RepoSummary }>(); 32 + const emit = defineEmits<{ click: [] }>(); 33 + 34 + const LANG_COLORS: Record<string, string> = { 35 + TypeScript: "#3178c6", 36 + JavaScript: "#f7df1e", 37 + Go: "#00add8", 38 + Python: "#3572A5", 39 + Rust: "#dea584", 40 + Nix: "#7ebae4", 41 + Ruby: "#cc342d", 42 + CSS: "#563d7c", 43 + HTML: "#e34c26", 44 + Shell: "#89e051", 45 + Swift: "#F05138", 46 + Kotlin: "#A97BFF", 47 + Dart: "#00B4AB", 48 + }; 49 + 50 + function langColor(lang: string): string { 51 + return LANG_COLORS[lang] ?? "var(--t-text-muted)"; 52 + } 53 + 54 + function formatCount(n: number): string { 55 + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); 56 + } 57 + 58 + function relativeTime(iso: string): string { 59 + const diff = Date.now() - new Date(iso).getTime(); 60 + const m = Math.floor(diff / 60000); 61 + const h = Math.floor(m / 60); 62 + const d = Math.floor(h / 24); 63 + if (d > 0) return `${d}d ago`; 64 + if (h > 0) return `${h}h ago`; 65 + if (m > 0) return `${m}m ago`; 66 + return "just now"; 67 + } 68 + </script> 69 + 70 + <style scoped> 71 + .repo-card { 72 + --background: var(--t-surface); 73 + margin: 6px 16px; 74 + border-radius: var(--t-radius-md); 75 + border: 1px solid var(--t-border); 76 + box-shadow: none; 77 + } 78 + 79 + .card-body { 80 + padding: 14px 16px; 81 + } 82 + 83 + .repo-header { 84 + display: flex; 85 + align-items: center; 86 + gap: 0; 87 + margin-bottom: 6px; 88 + } 89 + 90 + .repo-owner { 91 + font-family: var(--t-mono); 92 + font-size: 13px; 93 + color: var(--t-text-secondary); 94 + line-height: 1.4; 95 + } 96 + 97 + .repo-name { 98 + font-family: var(--t-mono); 99 + font-size: 13px; 100 + font-weight: 600; 101 + color: var(--t-accent); 102 + line-height: 1.4; 103 + flex: 1; 104 + } 105 + 106 + .stars { 107 + display: flex; 108 + align-items: center; 109 + gap: 3px; 110 + margin-left: auto; 111 + padding-left: 8px; 112 + flex-shrink: 0; 113 + } 114 + 115 + .star-icon { 116 + font-size: 12px; 117 + color: var(--t-amber); 118 + } 119 + 120 + .star-count { 121 + font-family: var(--t-mono); 122 + font-size: 12px; 123 + color: var(--t-text-secondary); 124 + } 125 + 126 + .repo-description { 127 + font-size: 13px; 128 + color: var(--t-text-secondary); 129 + margin: 0 0 10px; 130 + line-height: 1.5; 131 + display: -webkit-box; 132 + line-clamp: 2; 133 + -webkit-line-clamp: 2; 134 + -webkit-box-orient: vertical; 135 + overflow: hidden; 136 + } 137 + 138 + .repo-meta { 139 + display: flex; 140 + align-items: center; 141 + gap: 6px; 142 + } 143 + 144 + .lang-badge { 145 + display: flex; 146 + align-items: center; 147 + gap: 5px; 148 + font-size: 12px; 149 + color: var(--t-text-secondary); 150 + } 151 + 152 + .lang-dot { 153 + display: inline-block; 154 + width: 10px; 155 + height: 10px; 156 + border-radius: 50%; 157 + flex-shrink: 0; 158 + } 159 + 160 + .meta-dot { 161 + color: var(--t-text-muted); 162 + font-size: 12px; 163 + } 164 + 165 + .updated-at { 166 + font-size: 12px; 167 + color: var(--t-text-muted); 168 + } 169 + </style>
+166
src/components/common/SkeletonLoader.vue
··· 1 + <template> 2 + <!-- card variant --> 3 + <ion-card v-if="variant === 'card'" class="skeleton-card"> 4 + <ion-card-content class="card-body"> 5 + <div class="row space-between"> 6 + <ion-skeleton-text animated class="skel skel-title" /> 7 + <ion-skeleton-text animated class="skel skel-stars" /> 8 + </div> 9 + <ion-skeleton-text animated class="skel skel-desc" /> 10 + <ion-skeleton-text animated class="skel skel-desc-short" /> 11 + <div class="row"> 12 + <ion-skeleton-text animated class="skel skel-badge" /> 13 + <ion-skeleton-text animated class="skel skel-time" /> 14 + </div> 15 + </ion-card-content> 16 + </ion-card> 17 + 18 + <!-- list-item variant --> 19 + <ion-item v-else-if="variant === 'list-item'" class="skeleton-item" lines="none"> 20 + <ion-skeleton-text animated slot="start" class="skel skel-avatar" /> 21 + <ion-label> 22 + <ion-skeleton-text animated class="skel skel-item-title" /> 23 + <ion-skeleton-text animated class="skel skel-item-sub" /> 24 + </ion-label> 25 + </ion-item> 26 + 27 + <!-- profile variant --> 28 + <div v-else-if="variant === 'profile'" class="skeleton-profile"> 29 + <ion-skeleton-text animated class="skel skel-profile-avatar" /> 30 + <div class="profile-lines"> 31 + <ion-skeleton-text animated class="skel skel-profile-handle" /> 32 + <ion-skeleton-text animated class="skel skel-profile-name" /> 33 + <ion-skeleton-text animated class="skel skel-profile-bio" /> 34 + <ion-skeleton-text animated class="skel skel-profile-bio-short" /> 35 + </div> 36 + </div> 37 + </template> 38 + 39 + <script setup lang="ts"> 40 + import { IonCard, IonCardContent, IonItem, IonLabel, IonSkeletonText } from "@ionic/vue"; 41 + 42 + defineProps<{ variant: "card" | "list-item" | "profile" }>(); 43 + </script> 44 + 45 + <style scoped> 46 + /* shared */ 47 + .skel { 48 + border-radius: 4px; 49 + line-height: 1; 50 + } 51 + 52 + /* card */ 53 + .skeleton-card { 54 + --background: var(--t-surface); 55 + margin: 6px 16px; 56 + border-radius: var(--t-radius-md); 57 + border: 1px solid var(--t-border); 58 + box-shadow: none; 59 + } 60 + 61 + .card-body { 62 + padding: 14px 16px; 63 + } 64 + 65 + .row { 66 + display: flex; 67 + align-items: center; 68 + gap: 8px; 69 + } 70 + 71 + .space-between { 72 + justify-content: space-between; 73 + margin-bottom: 10px; 74 + } 75 + 76 + .skel-title { 77 + height: 13px; 78 + width: 55%; 79 + } 80 + .skel-stars { 81 + height: 12px; 82 + width: 40px; 83 + flex-shrink: 0; 84 + } 85 + .skel-desc { 86 + height: 12px; 87 + width: 90%; 88 + margin-bottom: 5px; 89 + } 90 + .skel-desc-short { 91 + height: 12px; 92 + width: 60%; 93 + margin-bottom: 12px; 94 + } 95 + .skel-badge { 96 + height: 10px; 97 + width: 80px; 98 + } 99 + .skel-time { 100 + height: 10px; 101 + width: 50px; 102 + } 103 + 104 + /* list-item */ 105 + .skeleton-item { 106 + --background: transparent; 107 + --padding-start: 16px; 108 + --inner-padding-end: 16px; 109 + } 110 + 111 + .skel-avatar { 112 + width: 36px; 113 + height: 36px; 114 + border-radius: 50%; 115 + flex-shrink: 0; 116 + margin-right: 12px; 117 + } 118 + .skel-item-title { 119 + height: 13px; 120 + width: 50%; 121 + margin-bottom: 7px; 122 + } 123 + .skel-item-sub { 124 + height: 11px; 125 + width: 35%; 126 + } 127 + 128 + /* profile */ 129 + .skeleton-profile { 130 + display: flex; 131 + gap: 14px; 132 + padding: 16px; 133 + } 134 + 135 + .skel-profile-avatar { 136 + width: 56px; 137 + height: 56px; 138 + border-radius: var(--t-radius-sm); 139 + flex-shrink: 0; 140 + } 141 + 142 + .profile-lines { 143 + flex: 1; 144 + display: flex; 145 + flex-direction: column; 146 + gap: 7px; 147 + padding-top: 4px; 148 + } 149 + 150 + .skel-profile-handle { 151 + height: 12px; 152 + width: 55%; 153 + } 154 + .skel-profile-name { 155 + height: 13px; 156 + width: 45%; 157 + } 158 + .skel-profile-bio { 159 + height: 11px; 160 + width: 90%; 161 + } 162 + .skel-profile-bio-short { 163 + height: 11px; 164 + width: 70%; 165 + } 166 + </style>
+149
src/components/common/UserCard.vue
··· 1 + <template> 2 + <ion-card class="user-card" button @click="emit('click')"> 3 + <ion-card-content class="card-body"> 4 + <div class="user-row"> 5 + <ion-avatar class="avatar"> 6 + <img v-if="user.avatar" :src="user.avatar" :alt="user.displayName ?? user.handle" /> 7 + <div v-else class="avatar-fallback" :style="{ background: avatarColor(user.handle) }"> 8 + {{ initials(user.handle) }} 9 + </div> 10 + </ion-avatar> 11 + 12 + <div class="user-info"> 13 + <div class="user-handle">{{ user.handle }}</div> 14 + <div v-if="user.displayName" class="user-display-name">{{ user.displayName }}</div> 15 + <p v-if="user.bio" class="user-bio">{{ user.bio }}</p> 16 + </div> 17 + </div> 18 + 19 + <div v-if="user.followerCount != null || user.followingCount != null" class="user-stats"> 20 + <span v-if="user.followerCount != null" class="stat"> 21 + <strong>{{ formatCount(user.followerCount) }}</strong> followers 22 + </span> 23 + <span v-if="user.followingCount != null" class="stat"> 24 + <strong>{{ formatCount(user.followingCount) }}</strong> following 25 + </span> 26 + </div> 27 + </ion-card-content> 28 + </ion-card> 29 + </template> 30 + 31 + <script setup lang="ts"> 32 + import { IonCard, IonCardContent, IonAvatar } from "@ionic/vue"; 33 + import type { UserSummary } from "@/domain/models/user"; 34 + 35 + defineProps<{ user: UserSummary }>(); 36 + const emit = defineEmits<{ click: [] }>(); 37 + 38 + const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 39 + 40 + function avatarColor(handle: string): string { 41 + let hash = 0; 42 + for (const ch of handle) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff; 43 + return PALETTE[Math.abs(hash) % PALETTE.length]; 44 + } 45 + 46 + function initials(handle: string): string { 47 + const base = handle.split(".")[0]; 48 + return base.slice(0, 2).toUpperCase(); 49 + } 50 + 51 + function formatCount(n: number): string { 52 + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); 53 + } 54 + </script> 55 + 56 + <style scoped> 57 + .user-card { 58 + --background: var(--t-surface); 59 + margin: 6px 16px; 60 + border-radius: var(--t-radius-md); 61 + border: 1px solid var(--t-border); 62 + box-shadow: none; 63 + } 64 + 65 + .card-body { 66 + padding: 14px 16px; 67 + } 68 + 69 + .user-row { 70 + display: flex; 71 + align-items: flex-start; 72 + gap: 12px; 73 + } 74 + 75 + .avatar { 76 + width: 44px; 77 + height: 44px; 78 + flex-shrink: 0; 79 + border-radius: var(--t-radius-sm); 80 + overflow: hidden; 81 + } 82 + 83 + .avatar-fallback { 84 + width: 100%; 85 + height: 100%; 86 + display: flex; 87 + align-items: center; 88 + justify-content: center; 89 + font-family: var(--t-mono); 90 + font-size: 13px; 91 + font-weight: 700; 92 + color: #0d1117; 93 + border-radius: var(--t-radius-sm); 94 + } 95 + 96 + .user-info { 97 + flex: 1; 98 + min-width: 0; 99 + } 100 + 101 + .user-handle { 102 + font-family: var(--t-mono); 103 + font-size: 13px; 104 + font-weight: 600; 105 + color: var(--t-accent); 106 + line-height: 1.3; 107 + white-space: nowrap; 108 + overflow: hidden; 109 + text-overflow: ellipsis; 110 + } 111 + 112 + .user-display-name { 113 + font-size: 13px; 114 + font-weight: 500; 115 + color: var(--t-text-primary); 116 + margin-top: 1px; 117 + line-height: 1.3; 118 + } 119 + 120 + .user-bio { 121 + font-size: 12px; 122 + color: var(--t-text-secondary); 123 + margin: 4px 0 0; 124 + line-height: 1.4; 125 + display: -webkit-box; 126 + line-clamp: 2; 127 + -webkit-line-clamp: 2; 128 + -webkit-box-orient: vertical; 129 + overflow: hidden; 130 + } 131 + 132 + .user-stats { 133 + display: flex; 134 + gap: 14px; 135 + margin-top: 10px; 136 + padding-top: 10px; 137 + border-top: 1px solid var(--t-border); 138 + } 139 + 140 + .stat { 141 + font-size: 12px; 142 + color: var(--t-text-muted); 143 + } 144 + 145 + .stat strong { 146 + font-weight: 600; 147 + color: var(--t-text-secondary); 148 + } 149 + </style>
+89
src/components/repo/FileTreeItem.vue
··· 1 + <template> 2 + <ion-item class="file-item" :lines="lines" button @click="emit('click')"> 3 + <ion-icon 4 + slot="start" 5 + :icon="file.type === 'dir' ? folderOutline : documentOutline" 6 + class="file-icon" 7 + :class="file.type" /> 8 + <ion-label class="file-label"> 9 + <span class="file-name">{{ file.name }}</span> 10 + <span v-if="file.lastCommitMessage" class="commit-msg">{{ file.lastCommitMessage }}</span> 11 + </ion-label> 12 + <span v-if="file.type === 'dir'" class="chevron"> 13 + <ion-icon :icon="chevronForwardOutline" /> 14 + </span> 15 + </ion-item> 16 + </template> 17 + 18 + <script setup lang="ts"> 19 + import { IonItem, IonLabel, IonIcon } from "@ionic/vue"; 20 + import { folderOutline, documentOutline, chevronForwardOutline } from "ionicons/icons"; 21 + import type { RepoFile } from "@/domain/models/repo"; 22 + 23 + defineProps<{ file: RepoFile; lines?: "full" | "inset" | "none" }>(); 24 + 25 + const emit = defineEmits<{ click: [] }>(); 26 + </script> 27 + 28 + <style scoped> 29 + .file-item { 30 + --background: transparent; 31 + --padding-start: 16px; 32 + --inner-padding-end: 12px; 33 + --min-height: 46px; 34 + } 35 + 36 + .file-icon { 37 + font-size: 17px; 38 + margin-right: 10px; 39 + flex-shrink: 0; 40 + } 41 + 42 + .file-icon.dir { 43 + color: var(--t-amber); 44 + } 45 + 46 + .file-icon.file { 47 + color: var(--t-text-muted); 48 + } 49 + 50 + .file-icon.submodule { 51 + color: var(--t-purple); 52 + } 53 + 54 + .file-label { 55 + display: flex; 56 + flex-direction: row; 57 + align-items: center; 58 + gap: 0; 59 + min-width: 0; 60 + } 61 + 62 + .file-name { 63 + font-family: var(--t-mono); 64 + font-size: 13px; 65 + font-weight: 500; 66 + color: var(--t-text-primary); 67 + white-space: nowrap; 68 + overflow: hidden; 69 + text-overflow: ellipsis; 70 + flex-shrink: 0; 71 + max-width: 45%; 72 + } 73 + 74 + .commit-msg { 75 + font-size: 12px; 76 + color: var(--t-text-muted); 77 + white-space: nowrap; 78 + overflow: hidden; 79 + text-overflow: ellipsis; 80 + flex: 1; 81 + margin-left: 12px; 82 + } 83 + 84 + .chevron { 85 + font-size: 14px; 86 + color: var(--t-text-muted); 87 + flex-shrink: 0; 88 + } 89 + </style>
+31
src/components/repo/MarkdownRenderer.vue
··· 1 + <!-- TODO: markdown → HTML pipeline (e.g. marked + DOMPurify). --> 2 + <template> 3 + <div class="markdown-body"> 4 + <pre class="markdown-raw">{{ content }}</pre> 5 + </div> 6 + </template> 7 + 8 + <script setup lang="ts"> 9 + defineProps<{ content: string }>(); 10 + </script> 11 + 12 + <style scoped> 13 + .markdown-body { 14 + padding: 0 16px 24px; 15 + } 16 + 17 + .markdown-raw { 18 + font-family: var(--t-mono); 19 + font-size: 12px; 20 + color: var(--t-text-secondary); 21 + line-height: 1.6; 22 + white-space: pre-wrap; 23 + word-break: break-word; 24 + margin: 0; 25 + background: var(--t-surface-raised); 26 + border: 1px solid var(--t-border); 27 + border-radius: var(--t-radius-md); 28 + padding: 14px 16px; 29 + overflow-x: auto; 30 + } 31 + </style>
+100 -1
src/features/activity/ActivityPage.vue
··· 5 5 <ion-title>Activity</ion-title> 6 6 </ion-toolbar> 7 7 </ion-header> 8 + 8 9 <ion-content :fullscreen="true"> 9 10 <ion-header collapse="condense"> 10 11 <ion-toolbar> 11 12 <ion-title size="large">Activity</ion-title> 12 13 </ion-toolbar> 13 14 </ion-header> 15 + 16 + <!-- Filter chips --> 17 + <div class="filters-wrap"> 18 + <ion-chip 19 + v-for="f in FILTERS" 20 + :key="f.value" 21 + class="filter-chip" 22 + :class="{ active: activeFilter === f.value }" 23 + @click="activeFilter = f.value"> 24 + {{ f.label }} 25 + </ion-chip> 26 + </div> 27 + 28 + <!-- Activity list --> 29 + <ion-list lines="inset"> 30 + <template v-if="loading"> 31 + <SkeletonLoader v-for="n in 6" :key="n" variant="list-item" /> 32 + </template> 33 + <template v-else-if="filteredActivity.length"> 34 + <ActivityCard v-for="item in filteredActivity" :key="item.id" :item="item" /> 35 + </template> 36 + <template v-else> 37 + <EmptyState :icon="pulseOutline" title="No activity" message="Nothing here yet for this filter." /> 38 + </template> 39 + </ion-list> 14 40 </ion-content> 15 41 </ion-page> 16 42 </template> 17 43 18 44 <script setup lang="ts"> 19 - import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue'; 45 + import { ref, computed, onMounted } from "vue"; 46 + import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonChip } from "@ionic/vue"; 47 + import { pulseOutline } from "ionicons/icons"; 48 + import ActivityCard from "@/components/common/ActivityCard.vue"; 49 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 50 + import EmptyState from "@/components/common/EmptyState.vue"; 51 + import { getMockActivity } from "@/mocks/activity"; 52 + import type { ActivityItem } from "@/domain/models/activity"; 53 + 54 + const loading = ref(true); 55 + const allActivity = getMockActivity(); 56 + const activeFilter = ref<"all" | "repos" | "prs" | "issues" | "people">("all"); 57 + 58 + const FILTERS = [ 59 + { value: "all", label: "All" }, 60 + { value: "repos", label: "Repos" }, 61 + { value: "prs", label: "PRs" }, 62 + { value: "issues", label: "Issues" }, 63 + { value: "people", label: "People" }, 64 + ] as const; 65 + 66 + const KIND_GROUPS: Record<string, ActivityItem["kind"][]> = { 67 + repos: ["repo_created", "repo_starred"], 68 + prs: ["pr_opened", "pr_merged"], 69 + issues: ["issue_opened", "issue_closed"], 70 + people: ["user_followed"], 71 + }; 72 + 73 + const filteredActivity = computed(() => { 74 + if (activeFilter.value === "all") return allActivity; 75 + const kinds = KIND_GROUPS[activeFilter.value] ?? []; 76 + return allActivity.filter((a) => kinds.includes(a.kind)); 77 + }); 78 + 79 + onMounted(() => { 80 + setTimeout(() => { 81 + loading.value = false; 82 + }, 400); 83 + }); 20 84 </script> 85 + 86 + <style scoped> 87 + .filters-wrap { 88 + display: flex; 89 + gap: 6px; 90 + padding: 12px 16px 4px; 91 + overflow-x: auto; 92 + scrollbar-width: none; 93 + } 94 + 95 + .filters-wrap::-webkit-scrollbar { 96 + display: none; 97 + } 98 + 99 + .filter-chip { 100 + --background: var(--t-surface-raised); 101 + --color: var(--t-text-secondary); 102 + border: 1px solid var(--t-border); 103 + flex-shrink: 0; 104 + font-size: 13px; 105 + margin: 0; 106 + cursor: pointer; 107 + } 108 + 109 + .filter-chip.active { 110 + --background: var(--t-accent-dim); 111 + --color: var(--t-accent); 112 + border-color: var(--t-accent); 113 + } 114 + 115 + ion-list { 116 + background: transparent; 117 + padding: 0; 118 + } 119 + </style>
+65 -6
src/features/explore/ExplorePage.vue
··· 4 4 <ion-toolbar> 5 5 <ion-title>Explore</ion-title> 6 6 </ion-toolbar> 7 + <ion-toolbar> 8 + <ion-searchbar placeholder="Search repos and users…" :disabled="true" class="search-bar" /> 9 + </ion-toolbar> 10 + <ion-toolbar> 11 + <ion-segment v-model="tab" class="explore-segment"> 12 + <ion-segment-button value="repos">Repos</ion-segment-button> 13 + <ion-segment-button value="users">Users</ion-segment-button> 14 + </ion-segment> 15 + </ion-toolbar> 7 16 </ion-header> 17 + 8 18 <ion-content :fullscreen="true"> 9 - <ion-header collapse="condense"> 10 - <ion-toolbar> 11 - <ion-title size="large">Explore</ion-title> 12 - </ion-toolbar> 13 - </ion-header> 19 + <template v-if="loading"> 20 + <SkeletonLoader v-for="n in 4" :key="n" :variant="tab === 'repos' ? 'card' : 'list-item'" /> 21 + </template> 22 + 23 + <template v-else-if="tab === 'repos'"> 24 + <RepoCard v-for="repo in repos" :key="repo.atUri" :repo="repo" @click="navigateToRepo(repo)" /> 25 + </template> 26 + 27 + <template v-else> 28 + <UserCard v-for="user in users" :key="user.did" :user="user" /> 29 + </template> 14 30 </ion-content> 15 31 </ion-page> 16 32 </template> 17 33 18 34 <script setup lang="ts"> 19 - import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue'; 35 + import { ref, onMounted } from "vue"; 36 + import { useRouter } from "vue-router"; 37 + import { 38 + IonPage, 39 + IonHeader, 40 + IonToolbar, 41 + IonTitle, 42 + IonContent, 43 + IonSearchbar, 44 + IonSegment, 45 + IonSegmentButton, 46 + } from "@ionic/vue"; 47 + import RepoCard from "@/components/common/RepoCard.vue"; 48 + import UserCard from "@/components/common/UserCard.vue"; 49 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 50 + import { getMockRepos } from "@/mocks/repos"; 51 + import { getMockUsers } from "@/mocks/users"; 52 + import type { RepoSummary } from "@/domain/models/repo"; 53 + 54 + const router = useRouter(); 55 + const tab = ref<"repos" | "users">("repos"); 56 + const loading = ref(true); 57 + const repos = getMockRepos(); 58 + const users = getMockUsers(); 59 + 60 + onMounted(() => { 61 + setTimeout(() => { 62 + loading.value = false; 63 + }, 400); 64 + }); 65 + 66 + function navigateToRepo(repo: RepoSummary) { 67 + router.push(`/tabs/explore/repo/${repo.ownerHandle}/${repo.name}`); 68 + } 20 69 </script> 70 + 71 + <style scoped> 72 + .search-bar { 73 + --background: var(--t-surface-raised); 74 + } 75 + 76 + .explore-segment { 77 + padding: 0 12px 6px; 78 + } 79 + </style>
+69 -1
src/features/home/HomePage.vue
··· 5 5 <ion-title>Home</ion-title> 6 6 </ion-toolbar> 7 7 </ion-header> 8 + 8 9 <ion-content :fullscreen="true"> 9 10 <ion-header collapse="condense"> 10 11 <ion-toolbar> 11 12 <ion-title size="large">Home</ion-title> 12 13 </ion-toolbar> 13 14 </ion-header> 15 + 16 + <!-- Trending Repos --> 17 + <div class="section"> 18 + <h2 class="section-title">Trending</h2> 19 + <template v-if="loading"> 20 + <SkeletonLoader v-for="n in 3" :key="n" variant="card" /> 21 + </template> 22 + <template v-else> 23 + <RepoCard v-for="repo in trendingRepos" :key="repo.atUri" :repo="repo" @click="navigateToRepo(repo)" /> 24 + </template> 25 + </div> 26 + 27 + <!-- Recent Activity --> 28 + <div class="section"> 29 + <h2 class="section-title">Recent Activity</h2> 30 + <ion-list lines="inset"> 31 + <template v-if="loading"> 32 + <SkeletonLoader v-for="n in 5" :key="n" variant="list-item" /> 33 + </template> 34 + <template v-else> 35 + <ActivityCard v-for="item in activity" :key="item.id" :item="item" /> 36 + </template> 37 + </ion-list> 38 + </div> 14 39 </ion-content> 15 40 </ion-page> 16 41 </template> 17 42 18 43 <script setup lang="ts"> 19 - import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue'; 44 + import { ref, onMounted } from "vue"; 45 + import { useRouter } from "vue-router"; 46 + import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonList } from "@ionic/vue"; 47 + import RepoCard from "@/components/common/RepoCard.vue"; 48 + import ActivityCard from "@/components/common/ActivityCard.vue"; 49 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 50 + import { getTrendingRepos } from "@/mocks/repos"; 51 + import { getMockActivity } from "@/mocks/activity"; 52 + import type { RepoSummary } from "@/domain/models/repo"; 53 + 54 + const router = useRouter(); 55 + const loading = ref(true); 56 + const trendingRepos = ref(getTrendingRepos()); 57 + const activity = ref(getMockActivity().slice(0, 8)); 58 + 59 + onMounted(() => { 60 + setTimeout(() => { 61 + loading.value = false; 62 + }, 400); 63 + }); 64 + 65 + function navigateToRepo(repo: RepoSummary) { 66 + router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`); 67 + } 20 68 </script> 69 + 70 + <style scoped> 71 + .section { 72 + margin-top: 8px; 73 + } 74 + 75 + .section-title { 76 + font-size: 13px; 77 + font-weight: 600; 78 + text-transform: uppercase; 79 + letter-spacing: 0.06em; 80 + color: var(--t-text-muted); 81 + margin: 16px 16px 6px; 82 + } 83 + 84 + ion-list { 85 + background: transparent; 86 + padding: 0; 87 + } 88 + </style>
+95 -1
src/features/profile/ProfilePage.vue
··· 5 5 <ion-title>Profile</ion-title> 6 6 </ion-toolbar> 7 7 </ion-header> 8 + 8 9 <ion-content :fullscreen="true"> 9 10 <ion-header collapse="condense"> 10 11 <ion-toolbar> 11 12 <ion-title size="large">Profile</ion-title> 12 13 </ion-toolbar> 13 14 </ion-header> 15 + 16 + <div class="signin-container"> 17 + <div class="brand-icon"> 18 + <ion-icon :icon="codeSlashOutline" /> 19 + </div> 20 + 21 + <h2 class="signin-title">Sign in to Tangled</h2> 22 + <p class="signin-subtitle"> 23 + Use your AT Protocol handle to sign in and access your starred repos, follow developers, and get a 24 + personalized activity feed. 25 + </p> 26 + 27 + <ion-button class="signin-btn" expand="block" @click="handleSignIn"> 28 + <ion-icon slot="start" :icon="logInOutline" /> 29 + Sign in with AT Protocol 30 + </ion-button> 31 + 32 + <p class="signin-hint"> 33 + Don't have a handle? 34 + <span class="signin-link">Get one at bsky.app</span> 35 + </p> 36 + </div> 14 37 </ion-content> 15 38 </ion-page> 16 39 </template> 17 40 18 41 <script setup lang="ts"> 19 - import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue'; 42 + import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonIcon } from "@ionic/vue"; 43 + import { codeSlashOutline, logInOutline } from "ionicons/icons"; 44 + 45 + function handleSignIn() { 46 + // TODO: AT Protocol OAuth flow 47 + } 20 48 </script> 49 + 50 + <style scoped> 51 + .signin-container { 52 + display: flex; 53 + flex-direction: column; 54 + align-items: center; 55 + justify-content: center; 56 + min-height: 70vh; 57 + padding: 32px 28px; 58 + text-align: center; 59 + gap: 14px; 60 + } 61 + 62 + .brand-icon { 63 + width: 72px; 64 + height: 72px; 65 + border-radius: var(--t-radius-lg); 66 + background: var(--t-accent-dim); 67 + border: 1px solid var(--t-border-strong); 68 + display: flex; 69 + align-items: center; 70 + justify-content: center; 71 + font-size: 30px; 72 + color: var(--t-accent); 73 + margin-bottom: 4px; 74 + } 75 + 76 + .signin-title { 77 + font-size: 22px; 78 + font-weight: 700; 79 + color: var(--t-text-primary); 80 + margin: 0; 81 + line-height: 1.2; 82 + } 83 + 84 + .signin-subtitle { 85 + font-size: 14px; 86 + color: var(--t-text-secondary); 87 + margin: 0; 88 + line-height: 1.55; 89 + max-width: 280px; 90 + } 91 + 92 + .signin-btn { 93 + --background: var(--t-accent); 94 + --background-activated: var(--t-accent); 95 + --color: #0d1117; 96 + --border-radius: var(--t-radius-md); 97 + width: 100%; 98 + max-width: 320px; 99 + font-weight: 600; 100 + font-size: 15px; 101 + margin-top: 6px; 102 + } 103 + 104 + .signin-hint { 105 + font-size: 13px; 106 + color: var(--t-text-muted); 107 + margin: 4px 0 0; 108 + } 109 + 110 + .signin-link { 111 + color: var(--t-accent); 112 + cursor: pointer; 113 + } 114 + </style>
+84 -10
src/features/repo/RepoDetailPage.vue
··· 5 5 <ion-buttons slot="start"> 6 6 <ion-back-button default-href="/tabs/home" /> 7 7 </ion-buttons> 8 - <ion-title>{{ owner }}/{{ repo }}</ion-title> 8 + <ion-title class="repo-title"> 9 + <span class="owner">{{ owner }}/</span>{{ repoName }} 10 + </ion-title> 11 + </ion-toolbar> 12 + <ion-toolbar> 13 + <ion-segment v-model="segment" class="detail-segment"> 14 + <ion-segment-button value="overview">Overview</ion-segment-button> 15 + <ion-segment-button value="files">Files</ion-segment-button> 16 + <ion-segment-button value="issues">Issues</ion-segment-button> 17 + <ion-segment-button value="prs">PRs</ion-segment-button> 18 + </ion-segment> 9 19 </ion-toolbar> 10 20 </ion-header> 21 + 11 22 <ion-content :fullscreen="true"> 23 + <!-- Loading skeleton --> 24 + <template v-if="loading"> 25 + <SkeletonLoader variant="profile" /> 26 + <SkeletonLoader v-for="n in 3" :key="n" variant="card" /> 27 + </template> 28 + 29 + <!-- Not found --> 30 + <EmptyState 31 + v-else-if="!repo" 32 + :icon="alertCircleOutline" 33 + title="Repo not found" 34 + message="This repository doesn't exist or hasn't been loaded yet." 35 + /> 36 + 37 + <!-- Content --> 38 + <template v-else> 39 + <RepoOverview v-if="segment === 'overview'" :repo="repo" /> 40 + <RepoFiles v-else-if="segment === 'files'" :files="files" /> 41 + <RepoIssues v-else-if="segment === 'issues'" :issues="issues" /> 42 + <RepoPRs v-else-if="segment === 'prs'" :prs="prs" /> 43 + </template> 12 44 </ion-content> 13 45 </ion-page> 14 46 </template> 15 47 16 48 <script setup lang="ts"> 49 + import { ref, onMounted } from 'vue'; 50 + import { useRoute } from 'vue-router'; 17 51 import { 18 - IonPage, 19 - IonHeader, 20 - IonToolbar, 21 - IonTitle, 22 - IonContent, 23 - IonButtons, 24 - IonBackButton, 52 + IonPage, IonHeader, IonToolbar, IonTitle, IonContent, 53 + IonButtons, IonBackButton, IonSegment, IonSegmentButton, 25 54 } from '@ionic/vue'; 26 - import { useRoute } from 'vue-router'; 55 + import { alertCircleOutline } from 'ionicons/icons'; 56 + import SkeletonLoader from '@/components/common/SkeletonLoader.vue'; 57 + import EmptyState from '@/components/common/EmptyState.vue'; 58 + import RepoOverview from './RepoOverview.vue'; 59 + import RepoFiles from './RepoFiles.vue'; 60 + import RepoIssues from './RepoIssues.vue'; 61 + import RepoPRs from './RepoPRs.vue'; 62 + import { getMockRepoDetail, getMockRepoFiles } from '@/mocks/repos'; 63 + import { getMockIssues } from '@/mocks/issues'; 64 + import { getMockPullRequests } from '@/mocks/pull-requests'; 65 + import type { RepoDetail, RepoFile } from '@/domain/models/repo'; 66 + import type { IssueSummary } from '@/domain/models/issue'; 67 + import type { PullRequestSummary } from '@/domain/models/pull-request'; 27 68 28 69 const route = useRoute(); 29 70 const owner = route.params.owner as string; 30 - const repo = route.params.repo as string; 71 + const repoName = route.params.repo as string; 72 + 73 + const segment = ref<'overview' | 'files' | 'issues' | 'prs'>('overview'); 74 + const loading = ref(true); 75 + const repo = ref<RepoDetail | null>(null); 76 + const files = ref<RepoFile[]>([]); 77 + const issues = ref<IssueSummary[]>([]); 78 + const prs = ref<PullRequestSummary[]>([]); 79 + 80 + onMounted(() => { 81 + setTimeout(() => { 82 + repo.value = getMockRepoDetail(owner, repoName) ?? null; 83 + files.value = getMockRepoFiles(); 84 + issues.value = getMockIssues(); 85 + prs.value = getMockPullRequests(); 86 + loading.value = false; 87 + }, 400); 88 + }); 31 89 </script> 90 + 91 + <style scoped> 92 + .repo-title { 93 + font-family: var(--t-mono); 94 + font-size: 14px; 95 + } 96 + 97 + .owner { 98 + color: var(--t-text-muted); 99 + font-weight: 400; 100 + } 101 + 102 + .detail-segment { 103 + padding: 0 12px 6px; 104 + } 105 + </style>
+53
src/features/repo/RepoFiles.vue
··· 1 + <template> 2 + <div class="files-view"> 3 + <ion-list lines="inset" class="file-list"> 4 + <FileTreeItem 5 + v-for="file in sortedFiles" 6 + :key="file.name" 7 + :file="file" 8 + lines="inset" 9 + @click="handleFileClick(file)" /> 10 + </ion-list> 11 + 12 + <EmptyState 13 + v-if="!files.length" 14 + :icon="folderOpenOutline" 15 + title="No files" 16 + message="This repository appears to be empty." /> 17 + </div> 18 + </template> 19 + 20 + <script setup lang="ts"> 21 + import { computed } from "vue"; 22 + import { IonList } from "@ionic/vue"; 23 + import { folderOpenOutline } from "ionicons/icons"; 24 + import FileTreeItem from "@/components/repo/FileTreeItem.vue"; 25 + import EmptyState from "@/components/common/EmptyState.vue"; 26 + import type { RepoFile } from "@/domain/models/repo"; 27 + 28 + const props = defineProps<{ files: RepoFile[] }>(); 29 + 30 + /* Sort dirs first, then files, alphabetically within each group */ 31 + const sortedFiles = computed(() => { 32 + return [...props.files].sort((a, b) => { 33 + if (a.type === b.type) return a.name.localeCompare(b.name); 34 + return a.type === "dir" ? -1 : 1; 35 + }); 36 + }); 37 + 38 + function handleFileClick(file: RepoFile) { 39 + // TODO: navigate into dir or open file viewer 40 + console.log("file clicked:", file.path, file.name); 41 + } 42 + </script> 43 + 44 + <style scoped> 45 + .files-view { 46 + padding-bottom: 32px; 47 + } 48 + 49 + .file-list { 50 + background: transparent; 51 + padding: 8px 0; 52 + } 53 + </style>
+185
src/features/repo/RepoIssues.vue
··· 1 + <template> 2 + <div class="issues-view"> 3 + <!-- Filter --> 4 + <div class="filters-row"> 5 + <ion-chip 6 + v-for="f in FILTERS" 7 + :key="f.value" 8 + class="filter-chip" 9 + :class="{ active: filter === f.value }" 10 + @click="filter = f.value"> 11 + {{ f.label }} 12 + </ion-chip> 13 + </div> 14 + 15 + <ion-list lines="inset" class="issue-list"> 16 + <ion-item v-for="issue in filtered" :key="issue.atUri" class="issue-item" button lines="inset"> 17 + <div slot="start" class="state-dot" :class="issue.state" /> 18 + <ion-label class="issue-label"> 19 + <span class="issue-title">{{ issue.title }}</span> 20 + <div class="issue-meta"> 21 + <span class="mono">{{ issue.authorHandle }}</span> 22 + <span class="sep">·</span> 23 + <span>{{ relativeTime(issue.createdAt) }}</span> 24 + <template v-if="issue.commentCount"> 25 + <span class="sep">·</span> 26 + <ion-icon :icon="chatbubbleOutline" class="meta-icon" /> 27 + <span>{{ issue.commentCount }}</span> 28 + </template> 29 + </div> 30 + </ion-label> 31 + <ion-badge slot="end" class="state-badge" :class="issue.state"> 32 + {{ issue.state }} 33 + </ion-badge> 34 + </ion-item> 35 + </ion-list> 36 + 37 + <EmptyState 38 + v-if="!filtered.length" 39 + :icon="alertCircleOutline" 40 + title="No issues" 41 + :message="filter === 'all' ? 'No issues filed yet.' : `No ${filter} issues.`" /> 42 + </div> 43 + </template> 44 + 45 + <script setup lang="ts"> 46 + import { ref, computed } from "vue"; 47 + import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip } from "@ionic/vue"; 48 + import { chatbubbleOutline, alertCircleOutline } from "ionicons/icons"; 49 + import EmptyState from "@/components/common/EmptyState.vue"; 50 + import type { IssueSummary } from "@/domain/models/issue"; 51 + 52 + const props = defineProps<{ issues: IssueSummary[] }>(); 53 + 54 + const filter = ref<"all" | "open" | "closed">("open"); 55 + 56 + const FILTERS = [ 57 + { value: "open", label: "Open" }, 58 + { value: "closed", label: "Closed" }, 59 + { value: "all", label: "All" }, 60 + ] as const; 61 + 62 + const filtered = computed(() => { 63 + if (filter.value === "all") return props.issues; 64 + return props.issues.filter((i) => i.state === filter.value); 65 + }); 66 + 67 + function relativeTime(iso: string): string { 68 + const diff = Date.now() - new Date(iso).getTime(); 69 + const m = Math.floor(diff / 60000); 70 + const h = Math.floor(m / 60); 71 + const d = Math.floor(h / 24); 72 + if (d > 0) return `${d}d ago`; 73 + if (h > 0) return `${h}h ago`; 74 + if (m > 0) return `${m}m ago`; 75 + return "just now"; 76 + } 77 + </script> 78 + 79 + <style scoped> 80 + .issues-view { 81 + padding-bottom: 32px; 82 + } 83 + 84 + .filters-row { 85 + display: flex; 86 + gap: 6px; 87 + padding: 12px 16px 8px; 88 + } 89 + 90 + .filter-chip { 91 + --background: var(--t-surface-raised); 92 + --color: var(--t-text-secondary); 93 + border: 1px solid var(--t-border); 94 + font-size: 13px; 95 + margin: 0; 96 + cursor: pointer; 97 + } 98 + 99 + .filter-chip.active { 100 + --background: var(--t-accent-dim); 101 + --color: var(--t-accent); 102 + border-color: var(--t-accent); 103 + } 104 + 105 + .issue-list { 106 + background: transparent; 107 + padding: 0; 108 + } 109 + 110 + .issue-item { 111 + --background: transparent; 112 + --padding-start: 16px; 113 + --inner-padding-end: 12px; 114 + } 115 + 116 + .state-dot { 117 + width: 8px; 118 + height: 8px; 119 + border-radius: 50%; 120 + flex-shrink: 0; 121 + margin-right: 12px; 122 + } 123 + 124 + .state-dot.open { 125 + background: var(--t-green); 126 + } 127 + .state-dot.closed { 128 + background: var(--t-text-muted); 129 + } 130 + 131 + .issue-label { 132 + white-space: normal; 133 + padding: 10px 0; 134 + } 135 + 136 + .issue-title { 137 + font-size: 13px; 138 + font-weight: 500; 139 + color: var(--t-text-primary); 140 + display: block; 141 + margin-bottom: 4px; 142 + line-height: 1.4; 143 + } 144 + 145 + .issue-meta { 146 + display: flex; 147 + align-items: center; 148 + gap: 4px; 149 + font-size: 12px; 150 + color: var(--t-text-muted); 151 + flex-wrap: wrap; 152 + } 153 + 154 + .mono { 155 + font-family: var(--t-mono); 156 + font-size: 11px; 157 + color: var(--t-accent); 158 + } 159 + 160 + .sep { 161 + color: var(--t-border-strong); 162 + } 163 + 164 + .meta-icon { 165 + font-size: 11px; 166 + } 167 + 168 + .state-badge { 169 + font-size: 11px; 170 + font-weight: 500; 171 + border-radius: 99px; 172 + padding: 2px 8px; 173 + text-transform: capitalize; 174 + } 175 + 176 + .state-badge.open { 177 + --background: var(--t-green-dim); 178 + --color: var(--t-green); 179 + } 180 + .state-badge.closed { 181 + --background: transparent; 182 + --color: var(--t-text-muted); 183 + border: 1px solid var(--t-border-strong); 184 + } 185 + </style>
+196
src/features/repo/RepoOverview.vue
··· 1 + <template> 2 + <div class="overview"> 3 + <!-- Header stats --> 4 + <div class="stats-row"> 5 + <div class="stat-item"> 6 + <ion-icon :icon="starOutline" class="stat-icon amber" /> 7 + <span class="stat-value">{{ repo.stars ?? 0 }}</span> 8 + <span class="stat-label">stars</span> 9 + </div> 10 + <div class="stat-item"> 11 + <ion-icon :icon="gitBranchOutline" class="stat-icon accent" /> 12 + <span class="stat-value">{{ repo.forks ?? 0 }}</span> 13 + <span class="stat-label">forks</span> 14 + </div> 15 + <div v-if="repo.defaultBranch" class="stat-item"> 16 + <ion-icon :icon="codeOutline" class="stat-icon muted" /> 17 + <span class="stat-value mono">{{ repo.defaultBranch }}</span> 18 + </div> 19 + </div> 20 + 21 + <!-- Description --> 22 + <p v-if="repo.description" class="repo-description">{{ repo.description }}</p> 23 + 24 + <!-- Topics --> 25 + <div v-if="repo.topics?.length" class="topics-row"> 26 + <ion-chip v-for="topic in repo.topics" :key="topic" class="topic-chip"> 27 + {{ topic }} 28 + </ion-chip> 29 + </div> 30 + 31 + <!-- Language breakdown --> 32 + <div v-if="repo.languages && Object.keys(repo.languages).length" class="section"> 33 + <h3 class="section-label">Languages</h3> 34 + <div class="lang-list"> 35 + <div v-for="[lang, pct] in langEntries" :key="lang" class="lang-row"> 36 + <span class="lang-dot" :style="{ background: langColor(lang) }" /> 37 + <span class="lang-name">{{ lang }}</span> 38 + <span class="lang-pct">{{ pct }}%</span> 39 + </div> 40 + </div> 41 + </div> 42 + 43 + <!-- README --> 44 + <div class="section"> 45 + <h3 class="section-label">README</h3> 46 + <MarkdownRenderer v-if="repo.readme" :content="repo.readme" /> 47 + <EmptyState v-else :icon="documentOutline" title="No README" message="This repo doesn't have a README yet." /> 48 + </div> 49 + </div> 50 + </template> 51 + 52 + <script setup lang="ts"> 53 + import { computed } from "vue"; 54 + import { IonIcon, IonChip } from "@ionic/vue"; 55 + import { starOutline, gitBranchOutline, codeOutline, documentOutline } from "ionicons/icons"; 56 + import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 57 + import EmptyState from "@/components/common/EmptyState.vue"; 58 + import type { RepoDetail } from "@/domain/models/repo"; 59 + 60 + const props = defineProps<{ repo: RepoDetail }>(); 61 + 62 + const LANG_COLORS: Record<string, string> = { 63 + TypeScript: "#3178c6", 64 + JavaScript: "#f7df1e", 65 + Go: "#00add8", 66 + Python: "#3572A5", 67 + Rust: "#dea584", 68 + Nix: "#7ebae4", 69 + Ruby: "#cc342d", 70 + CSS: "#563d7c", 71 + HTML: "#e34c26", 72 + }; 73 + 74 + function langColor(lang: string): string { 75 + return LANG_COLORS[lang] ?? "var(--t-text-muted)"; 76 + } 77 + 78 + const langEntries = computed(() => Object.entries(props.repo.languages ?? {}).sort(([, a], [, b]) => b - a)); 79 + </script> 80 + 81 + <style scoped> 82 + .overview { 83 + padding-bottom: 32px; 84 + } 85 + 86 + .stats-row { 87 + display: flex; 88 + gap: 20px; 89 + padding: 16px 16px 12px; 90 + border-bottom: 1px solid var(--t-border); 91 + } 92 + 93 + .stat-item { 94 + display: flex; 95 + align-items: center; 96 + gap: 5px; 97 + } 98 + 99 + .stat-icon { 100 + font-size: 14px; 101 + } 102 + 103 + .stat-icon.amber { 104 + color: var(--t-amber); 105 + } 106 + .stat-icon.accent { 107 + color: var(--t-accent); 108 + } 109 + .stat-icon.muted { 110 + color: var(--t-text-muted); 111 + } 112 + 113 + .stat-value { 114 + font-size: 13px; 115 + font-weight: 600; 116 + color: var(--t-text-primary); 117 + } 118 + 119 + .stat-value.mono { 120 + font-family: var(--t-mono); 121 + font-size: 12px; 122 + } 123 + 124 + .stat-label { 125 + font-size: 12px; 126 + color: var(--t-text-muted); 127 + } 128 + 129 + .repo-description { 130 + font-size: 14px; 131 + color: var(--t-text-secondary); 132 + margin: 14px 16px 0; 133 + line-height: 1.55; 134 + } 135 + 136 + .topics-row { 137 + display: flex; 138 + flex-wrap: wrap; 139 + gap: 6px; 140 + padding: 12px 16px 0; 141 + } 142 + 143 + .topic-chip { 144 + --background: var(--t-accent-dim); 145 + --color: var(--t-accent); 146 + border: 1px solid var(--t-border-strong); 147 + font-size: 12px; 148 + height: 26px; 149 + margin: 0; 150 + } 151 + 152 + .section { 153 + margin-top: 20px; 154 + } 155 + 156 + .section-label { 157 + font-size: 11px; 158 + font-weight: 600; 159 + text-transform: uppercase; 160 + letter-spacing: 0.07em; 161 + color: var(--t-text-muted); 162 + margin: 0 16px 10px; 163 + } 164 + 165 + .lang-list { 166 + display: flex; 167 + flex-direction: column; 168 + gap: 8px; 169 + padding: 0 16px; 170 + } 171 + 172 + .lang-row { 173 + display: flex; 174 + align-items: center; 175 + gap: 8px; 176 + } 177 + 178 + .lang-dot { 179 + width: 10px; 180 + height: 10px; 181 + border-radius: 50%; 182 + flex-shrink: 0; 183 + } 184 + 185 + .lang-name { 186 + font-size: 13px; 187 + color: var(--t-text-secondary); 188 + flex: 1; 189 + } 190 + 191 + .lang-pct { 192 + font-family: var(--t-mono); 193 + font-size: 12px; 194 + color: var(--t-text-muted); 195 + } 196 + </style>
+204
src/features/repo/RepoPRs.vue
··· 1 + <template> 2 + <div class="prs-view"> 3 + <!-- Filter --> 4 + <div class="filters-row"> 5 + <ion-chip 6 + v-for="f in FILTERS" 7 + :key="f.value" 8 + class="filter-chip" 9 + :class="{ active: filter === f.value }" 10 + @click="filter = f.value"> 11 + {{ f.label }} 12 + </ion-chip> 13 + </div> 14 + 15 + <ion-list lines="inset" class="pr-list"> 16 + <ion-item v-for="pr in filtered" :key="pr.atUri" class="pr-item" button lines="inset"> 17 + <div slot="start" class="status-icon" :class="pr.status"> 18 + <ion-icon :icon="gitMergeOutline" /> 19 + </div> 20 + <ion-label class="pr-label"> 21 + <span class="pr-title">{{ pr.title }}</span> 22 + <div class="pr-meta"> 23 + <span class="mono">{{ pr.authorHandle }}</span> 24 + <span class="sep">·</span> 25 + <span class="branch mono">{{ pr.sourceBranch }}</span> 26 + <span class="sep">→</span> 27 + <span class="branch mono">{{ pr.targetBranch }}</span> 28 + <span class="sep">·</span> 29 + <span>{{ relativeTime(pr.createdAt) }}</span> 30 + </div> 31 + </ion-label> 32 + <ion-badge slot="end" class="status-badge" :class="pr.status"> 33 + {{ pr.status }} 34 + </ion-badge> 35 + </ion-item> 36 + </ion-list> 37 + 38 + <EmptyState 39 + v-if="!filtered.length" 40 + :icon="gitMergeOutline" 41 + title="No pull requests" 42 + :message="filter === 'all' ? 'No PRs yet.' : `No ${filter} PRs.`" /> 43 + </div> 44 + </template> 45 + 46 + <script setup lang="ts"> 47 + import { ref, computed } from "vue"; 48 + import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip } from "@ionic/vue"; 49 + import { gitMergeOutline } from "ionicons/icons"; 50 + import EmptyState from "@/components/common/EmptyState.vue"; 51 + import type { PullRequestSummary } from "@/domain/models/pull-request"; 52 + 53 + const props = defineProps<{ prs: PullRequestSummary[] }>(); 54 + 55 + const filter = ref<"all" | "open" | "merged" | "closed">("open"); 56 + 57 + const FILTERS = [ 58 + { value: "open", label: "Open" }, 59 + { value: "merged", label: "Merged" }, 60 + { value: "closed", label: "Closed" }, 61 + { value: "all", label: "All" }, 62 + ] as const; 63 + 64 + const filtered = computed(() => { 65 + if (filter.value === "all") return props.prs; 66 + return props.prs.filter((pr) => pr.status === filter.value); 67 + }); 68 + 69 + function relativeTime(iso: string): string { 70 + const diff = Date.now() - new Date(iso).getTime(); 71 + const m = Math.floor(diff / 60000); 72 + const h = Math.floor(m / 60); 73 + const d = Math.floor(h / 24); 74 + if (d > 0) return `${d}d ago`; 75 + if (h > 0) return `${h}h ago`; 76 + if (m > 0) return `${m}m ago`; 77 + return "just now"; 78 + } 79 + </script> 80 + 81 + <style scoped> 82 + .prs-view { 83 + padding-bottom: 32px; 84 + } 85 + 86 + .filters-row { 87 + display: flex; 88 + gap: 6px; 89 + padding: 12px 16px 8px; 90 + } 91 + 92 + .filter-chip { 93 + --background: var(--t-surface-raised); 94 + --color: var(--t-text-secondary); 95 + border: 1px solid var(--t-border); 96 + font-size: 13px; 97 + margin: 0; 98 + cursor: pointer; 99 + } 100 + 101 + .filter-chip.active { 102 + --background: var(--t-accent-dim); 103 + --color: var(--t-accent); 104 + border-color: var(--t-accent); 105 + } 106 + 107 + .pr-list { 108 + background: transparent; 109 + padding: 0; 110 + } 111 + 112 + .pr-item { 113 + --background: transparent; 114 + --padding-start: 16px; 115 + --inner-padding-end: 12px; 116 + } 117 + 118 + .status-icon { 119 + display: flex; 120 + align-items: center; 121 + justify-content: center; 122 + width: 28px; 123 + height: 28px; 124 + border-radius: 50%; 125 + font-size: 14px; 126 + margin-right: 10px; 127 + flex-shrink: 0; 128 + } 129 + 130 + .status-icon.open { 131 + color: var(--t-accent); 132 + background: var(--t-accent-dim); 133 + } 134 + .status-icon.merged { 135 + color: var(--t-purple); 136 + background: rgba(167, 139, 250, 0.1); 137 + } 138 + .status-icon.closed { 139 + color: var(--t-text-muted); 140 + background: var(--t-surface-raised); 141 + } 142 + 143 + .pr-label { 144 + white-space: normal; 145 + padding: 10px 0; 146 + } 147 + 148 + .pr-title { 149 + font-size: 13px; 150 + font-weight: 500; 151 + color: var(--t-text-primary); 152 + display: block; 153 + margin-bottom: 4px; 154 + line-height: 1.4; 155 + } 156 + 157 + .pr-meta { 158 + display: flex; 159 + align-items: center; 160 + gap: 4px; 161 + font-size: 12px; 162 + color: var(--t-text-muted); 163 + flex-wrap: wrap; 164 + } 165 + 166 + .mono { 167 + font-family: var(--t-mono); 168 + font-size: 11px; 169 + } 170 + 171 + .mono:first-child { 172 + color: var(--t-accent); 173 + } 174 + 175 + .branch { 176 + color: var(--t-text-secondary); 177 + } 178 + 179 + .sep { 180 + color: var(--t-border-strong); 181 + } 182 + 183 + .status-badge { 184 + font-size: 11px; 185 + font-weight: 500; 186 + border-radius: 99px; 187 + padding: 2px 8px; 188 + text-transform: capitalize; 189 + } 190 + 191 + .status-badge.open { 192 + --background: var(--t-accent-dim); 193 + --color: var(--t-accent); 194 + } 195 + .status-badge.merged { 196 + --background: rgba(167, 139, 250, 0.1); 197 + --color: var(--t-purple); 198 + } 199 + .status-badge.closed { 200 + --background: transparent; 201 + --color: var(--t-text-muted); 202 + border: 1px solid var(--t-border-strong); 203 + } 204 + </style>
+117 -120
src/mocks/repos.ts
··· 1 - import type { RepoSummary, RepoDetail, RepoFile } from '@/domain/models/repo'; 1 + import type { RepoSummary, RepoDetail, RepoFile } from "@/domain/models/repo"; 2 2 3 - // Timestamps within the last 30 days (relative to 2026-03-22) 4 3 const MOCK_REPOS: RepoSummary[] = [ 5 - { 6 - atUri: 'at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer', 7 - ownerDid: 'did:plc:a1b2c3d4e5f6g7h8i9j0k1l2', 8 - ownerHandle: 'alice.tngl.sh', 9 - name: 'atproto-explorer', 10 - description: 'Interactive explorer for AT Protocol lexicons and records.', 11 - primaryLanguage: 'TypeScript', 12 - stars: 312, 13 - forks: 28, 14 - updatedAt: '2026-03-21T14:32:00Z', 15 - knot: 'tangled.sh', 16 - }, 17 - { 18 - atUri: 'at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/twisted', 19 - ownerDid: 'did:plc:p2cp5gopk7mgjegy9waligxd', 20 - ownerHandle: 'desertthunder.dev', 21 - name: 'twisted', 22 - description: 'A mobile companion reader for Tangled, built with Ionic Vue.', 23 - primaryLanguage: 'TypeScript', 24 - stars: 47, 25 - forks: 3, 26 - updatedAt: '2026-03-22T09:15:00Z', 27 - knot: 'tangled.sh', 28 - }, 29 - { 30 - atUri: 'at://did:plc:b2c3d4e5f6g7h8i9j0k1l2m3/sh.tangled.repo/git-log-pretty', 31 - ownerDid: 'did:plc:b2c3d4e5f6g7h8i9j0k1l2m3', 32 - ownerHandle: 'bob.tngl.sh', 33 - name: 'git-log-pretty', 34 - description: 'Opinionated git log formatter with colour themes and TUI.', 35 - primaryLanguage: 'Go', 36 - stars: 189, 37 - forks: 14, 38 - updatedAt: '2026-03-19T22:08:00Z', 39 - knot: 'tangled.sh', 40 - }, 41 - { 42 - atUri: 'at://did:plc:c3d4e5f6g7h8i9j0k1l2m3n4/sh.tangled.repo/iris-ui', 43 - ownerDid: 'did:plc:c3d4e5f6g7h8i9j0k1l2m3n4', 44 - ownerHandle: 'clara.bsky.social', 45 - name: 'iris-ui', 46 - description: 'Accessible component library for AT Protocol apps.', 47 - primaryLanguage: 'TypeScript', 48 - stars: 631, 49 - forks: 72, 50 - updatedAt: '2026-03-20T11:45:00Z', 51 - knot: 'tangled.sh', 52 - }, 53 - { 54 - atUri: 'at://did:plc:e5f6g7h8i9j0k1l2m3n4o5p6/sh.tangled.repo/nix-atproto', 55 - ownerDid: 'did:plc:e5f6g7h8i9j0k1l2m3n4o5p6', 56 - ownerHandle: 'riku.tngl.sh', 57 - name: 'nix-atproto', 58 - description: 'Nix flakes and modules for self-hosting AT Protocol services.', 59 - primaryLanguage: 'Nix', 60 - stars: 94, 61 - forks: 11, 62 - updatedAt: '2026-03-17T08:30:00Z', 63 - knot: 'tangled.sh', 64 - }, 65 - { 66 - atUri: 'at://did:plc:d4e5f6g7h8i9j0k1l2m3n4o5/sh.tangled.repo/tangled-cli', 67 - ownerDid: 'did:plc:d4e5f6g7h8i9j0k1l2m3n4o5', 68 - ownerHandle: 'dev.tangled.sh', 69 - name: 'tangled-cli', 70 - description: 'Official command-line tool for interacting with the Tangled platform.', 71 - primaryLanguage: 'Go', 72 - stars: 1842, 73 - forks: 203, 74 - updatedAt: '2026-03-22T07:00:00Z', 75 - knot: 'tangled.sh', 76 - }, 77 - { 78 - atUri: 'at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/lexicon-validator', 79 - ownerDid: 'did:plc:a1b2c3d4e5f6g7h8i9j0k1l2', 80 - ownerHandle: 'alice.tngl.sh', 81 - name: 'lexicon-validator', 82 - description: 'Runtime validation for AT Protocol lexicon schemas.', 83 - primaryLanguage: 'TypeScript', 84 - stars: 77, 85 - forks: 9, 86 - updatedAt: '2026-03-14T16:20:00Z', 87 - knot: 'tangled.sh', 88 - }, 89 - { 90 - atUri: 'at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/bsky-feeds', 91 - ownerDid: 'did:plc:p2cp5gopk7mgjegy9waligxd', 92 - ownerHandle: 'desertthunder.dev', 93 - name: 'bsky-feeds', 94 - description: 'Custom Bluesky feed generators with a simple declarative API.', 95 - primaryLanguage: 'Python', 96 - stars: 203, 97 - forks: 31, 98 - updatedAt: '2026-03-10T19:55:00Z', 99 - knot: 'tangled.sh', 100 - }, 4 + { 5 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/atproto-explorer", 6 + ownerDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 7 + ownerHandle: "alice.tngl.sh", 8 + name: "atproto-explorer", 9 + description: "Interactive explorer for AT Protocol lexicons and records.", 10 + primaryLanguage: "TypeScript", 11 + stars: 312, 12 + forks: 28, 13 + updatedAt: "2026-03-21T14:32:00Z", 14 + knot: "tangled.sh", 15 + }, 16 + { 17 + atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/twisted", 18 + ownerDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 19 + ownerHandle: "desertthunder.dev", 20 + name: "twisted", 21 + description: "A mobile companion reader for Tangled, built with Ionic Vue.", 22 + primaryLanguage: "TypeScript", 23 + stars: 47, 24 + forks: 3, 25 + updatedAt: "2026-03-22T09:15:00Z", 26 + knot: "tangled.sh", 27 + }, 28 + { 29 + atUri: "at://did:plc:b2c3d4e5f6g7h8i9j0k1l2m3/sh.tangled.repo/git-log-pretty", 30 + ownerDid: "did:plc:b2c3d4e5f6g7h8i9j0k1l2m3", 31 + ownerHandle: "bob.tngl.sh", 32 + name: "git-log-pretty", 33 + description: "Opinionated git log formatter with colour themes and TUI.", 34 + primaryLanguage: "Go", 35 + stars: 189, 36 + forks: 14, 37 + updatedAt: "2026-03-19T22:08:00Z", 38 + knot: "tangled.sh", 39 + }, 40 + { 41 + atUri: "at://did:plc:c3d4e5f6g7h8i9j0k1l2m3n4/sh.tangled.repo/iris-ui", 42 + ownerDid: "did:plc:c3d4e5f6g7h8i9j0k1l2m3n4", 43 + ownerHandle: "clara.bsky.social", 44 + name: "iris-ui", 45 + description: "Accessible component library for AT Protocol apps.", 46 + primaryLanguage: "TypeScript", 47 + stars: 631, 48 + forks: 72, 49 + updatedAt: "2026-03-20T11:45:00Z", 50 + knot: "tangled.sh", 51 + }, 52 + { 53 + atUri: "at://did:plc:e5f6g7h8i9j0k1l2m3n4o5p6/sh.tangled.repo/nix-atproto", 54 + ownerDid: "did:plc:e5f6g7h8i9j0k1l2m3n4o5p6", 55 + ownerHandle: "riku.tngl.sh", 56 + name: "nix-atproto", 57 + description: "Nix flakes and modules for self-hosting AT Protocol services.", 58 + primaryLanguage: "Nix", 59 + stars: 94, 60 + forks: 11, 61 + updatedAt: "2026-03-17T08:30:00Z", 62 + knot: "tangled.sh", 63 + }, 64 + { 65 + atUri: "at://did:plc:d4e5f6g7h8i9j0k1l2m3n4o5/sh.tangled.repo/tangled-cli", 66 + ownerDid: "did:plc:d4e5f6g7h8i9j0k1l2m3n4o5", 67 + ownerHandle: "dev.tangled.sh", 68 + name: "tangled-cli", 69 + description: "Official command-line tool for interacting with the Tangled platform.", 70 + primaryLanguage: "Go", 71 + stars: 1842, 72 + forks: 203, 73 + updatedAt: "2026-03-22T07:00:00Z", 74 + knot: "tangled.sh", 75 + }, 76 + { 77 + atUri: "at://did:plc:a1b2c3d4e5f6g7h8i9j0k1l2/sh.tangled.repo/lexicon-validator", 78 + ownerDid: "did:plc:a1b2c3d4e5f6g7h8i9j0k1l2", 79 + ownerHandle: "alice.tngl.sh", 80 + name: "lexicon-validator", 81 + description: "Runtime validation for AT Protocol lexicon schemas.", 82 + primaryLanguage: "TypeScript", 83 + stars: 77, 84 + forks: 9, 85 + updatedAt: "2026-03-14T16:20:00Z", 86 + knot: "tangled.sh", 87 + }, 88 + { 89 + atUri: "at://did:plc:p2cp5gopk7mgjegy9waligxd/sh.tangled.repo/bsky-feeds", 90 + ownerDid: "did:plc:p2cp5gopk7mgjegy9waligxd", 91 + ownerHandle: "desertthunder.dev", 92 + name: "bsky-feeds", 93 + description: "Custom Bluesky feed generators with a simple declarative API.", 94 + primaryLanguage: "Python", 95 + stars: 203, 96 + forks: 31, 97 + updatedAt: "2026-03-10T19:55:00Z", 98 + knot: "tangled.sh", 99 + }, 101 100 ]; 102 101 103 102 const MOCK_REPO_FILES: RepoFile[] = [ 104 - { path: '', name: 'src', type: 'dir', lastCommitMessage: 'feat: add skeleton loaders' }, 105 - { path: '', name: 'docs', type: 'dir', lastCommitMessage: 'docs: update phase-1 spec' }, 106 - { path: '', name: 'public', type: 'dir', lastCommitMessage: 'chore: add favicon' }, 107 - { path: '', name: '.gitignore', type: 'file', size: 412, lastCommitMessage: 'chore: initial scaffold' }, 108 - { path: '', name: 'package.json', type: 'file', size: 1840, lastCommitMessage: 'chore: add tanstack query' }, 109 - { path: '', name: 'README.md', type: 'file', size: 2310, lastCommitMessage: 'docs: update readme' }, 110 - { path: '', name: 'tsconfig.json', type: 'file', size: 688, lastCommitMessage: 'chore: initial scaffold' }, 111 - { path: '', name: 'vite.config.ts', type: 'file', size: 520, lastCommitMessage: 'chore: path aliases' }, 103 + { path: "", name: "src", type: "dir", lastCommitMessage: "feat: add skeleton loaders" }, 104 + { path: "", name: "docs", type: "dir", lastCommitMessage: "docs: update phase-1 spec" }, 105 + { path: "", name: "public", type: "dir", lastCommitMessage: "chore: add favicon" }, 106 + { path: "", name: ".gitignore", type: "file", size: 412, lastCommitMessage: "chore: initial scaffold" }, 107 + { path: "", name: "package.json", type: "file", size: 1840, lastCommitMessage: "chore: add tanstack query" }, 108 + { path: "", name: "README.md", type: "file", size: 2310, lastCommitMessage: "docs: update readme" }, 109 + { path: "", name: "tsconfig.json", type: "file", size: 688, lastCommitMessage: "chore: initial scaffold" }, 110 + { path: "", name: "vite.config.ts", type: "file", size: 520, lastCommitMessage: "chore: path aliases" }, 112 111 ]; 113 112 114 113 const README_CONTENT = `# twisted ··· 138 137 `; 139 138 140 139 export function getMockRepos(): RepoSummary[] { 141 - return MOCK_REPOS; 140 + return MOCK_REPOS; 142 141 } 143 142 144 143 export function getMockRepoDetail(ownerHandle: string, name: string): RepoDetail | undefined { 145 - const summary = MOCK_REPOS.find((r) => r.ownerHandle === ownerHandle && r.name === name); 146 - if (!summary) return undefined; 144 + const summary = MOCK_REPOS.find((r) => r.ownerHandle === ownerHandle && r.name === name); 145 + if (!summary) return undefined; 147 146 148 - return { 149 - ...summary, 150 - readme: README_CONTENT, 151 - defaultBranch: 'main', 152 - languages: summary.primaryLanguage 153 - ? { [summary.primaryLanguage]: 85, Other: 15 } 154 - : {}, 155 - topics: ['atproto', 'tangled', 'open-source'], 156 - }; 147 + return { 148 + ...summary, 149 + readme: README_CONTENT, 150 + defaultBranch: "main", 151 + languages: summary.primaryLanguage ? { [summary.primaryLanguage]: 85, Other: 15 } : {}, 152 + topics: ["atproto", "tangled", "open-source"], 153 + }; 157 154 } 158 155 159 156 export function getMockRepoFiles(): RepoFile[] { 160 - return MOCK_REPO_FILES; 157 + return MOCK_REPO_FILES; 161 158 } 162 159 163 160 export function getTrendingRepos(): RepoSummary[] { 164 - return [...MOCK_REPOS].sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0)).slice(0, 5); 161 + return [...MOCK_REPOS].sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0)).slice(0, 5); 165 162 }
-22
src/router/index.ts
··· 1 - import { createRouter, createWebHistory } from '@ionic/vue-router'; 2 - import { RouteRecordRaw } from 'vue-router'; 3 - import HomePage from '../views/HomePage.vue' 4 - 5 - const routes: Array<RouteRecordRaw> = [ 6 - { 7 - path: '/', 8 - redirect: '/home' 9 - }, 10 - { 11 - path: '/home', 12 - name: 'Home', 13 - component: HomePage 14 - } 15 - ] 16 - 17 - const router = createRouter({ 18 - history: createWebHistory(import.meta.env.BASE_URL), 19 - routes 20 - }) 21 - 22 - export default router
+52
src/theme/variables.css
··· 1 1 /* For information on how to create your own theme, please refer to: 2 2 http://ionicframework.com/docs/theming/ */ 3 + 4 + /* Twisted design tokens — light */ 5 + :root { 6 + --t-accent: #0ea5e9; 7 + --t-accent-dim: rgba(14, 165, 233, 0.1); 8 + --t-amber: #d97706; 9 + --t-amber-dim: rgba(217, 119, 6, 0.12); 10 + --t-green: #059669; 11 + --t-green-dim: rgba(5, 150, 105, 0.1); 12 + --t-red: #dc2626; 13 + --t-red-dim: rgba(220, 38, 38, 0.1); 14 + --t-purple: #7c3aed; 15 + 16 + --t-surface: #ffffff; 17 + --t-surface-raised: #f6f8fa; 18 + --t-border: rgba(0, 0, 0, 0.08); 19 + --t-border-strong: rgba(0, 0, 0, 0.14); 20 + 21 + --t-text-primary: #0d1117; 22 + --t-text-secondary: #57606a; 23 + --t-text-muted: #8c959f; 24 + 25 + --t-mono: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, "Courier New", monospace; 26 + 27 + --t-radius-sm: 6px; 28 + --t-radius-md: 10px; 29 + --t-radius-lg: 14px; 30 + } 31 + 32 + /* Twisted design tokens — dark */ 33 + @media (prefers-color-scheme: dark) { 34 + :root { 35 + --t-accent: #22d3ee; 36 + --t-accent-dim: rgba(34, 211, 238, 0.08); 37 + --t-amber: #fbbf24; 38 + --t-amber-dim: rgba(251, 191, 36, 0.1); 39 + --t-green: #34d399; 40 + --t-green-dim: rgba(52, 211, 153, 0.1); 41 + --t-red: #f87171; 42 + --t-red-dim: rgba(248, 113, 113, 0.1); 43 + --t-purple: #a78bfa; 44 + 45 + --t-surface: #161b22; 46 + --t-surface-raised: #1c2128; 47 + --t-border: rgba(255, 255, 255, 0.07); 48 + --t-border-strong: rgba(255, 255, 255, 0.12); 49 + 50 + --t-text-primary: #e6edf3; 51 + --t-text-secondary: #8b949e; 52 + --t-text-muted: #484f58; 53 + } 54 + }