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

Configure Feed

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

chore: prettier

+1805 -1852
+1
.gitignore
··· 33 33 www 34 34 35 35 *.db 36 + .env
+1 -1
apps/twisted/src/App.vue
··· 5 5 </template> 6 6 7 7 <script setup lang="ts"> 8 - import { IonApp, IonRouterOutlet } from "@ionic/vue"; 8 + import { IonApp, IonRouterOutlet } from "@ionic/vue"; 9 9 </script>
+105 -105
apps/twisted/src/components/common/ActivityCard.vue
··· 16 16 </template> 17 17 18 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.js"; 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.js"; 31 31 32 - const props = defineProps<{ item: ActivityItem }>(); 33 - const emit = defineEmits<{ click: []; actorClick: [] }>(); 32 + const props = defineProps<{ item: ActivityItem }>(); 33 + const emit = defineEmits<{ click: []; actorClick: [] }>(); 34 34 35 - type KindConfig = { icon: string; color: string; dimColor: string; verb: string }; 35 + type KindConfig = { icon: string; color: string; dimColor: string; verb: string }; 36 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 - }; 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 61 62 - const config = computed(() => KIND_MAP[props.item.kind]); 62 + const config = computed(() => KIND_MAP[props.item.kind]); 63 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 - } 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 74 </script> 75 75 76 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 - } 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 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 - } 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 96 97 - .activity-label { 98 - padding: 10px 0; 99 - white-space: normal; 100 - } 97 + .activity-label { 98 + padding: 10px 0; 99 + white-space: normal; 100 + } 101 101 102 - .activity-text { 103 - font-size: 13px; 104 - line-height: 1.45; 105 - color: var(--t-text-secondary); 106 - display: inline-flex; 107 - gap: 4px; 108 - } 102 + .activity-text { 103 + font-size: 13px; 104 + line-height: 1.45; 105 + color: var(--t-text-secondary); 106 + display: inline-flex; 107 + gap: 4px; 108 + } 109 109 110 - .actor { 111 - appearance: none; 112 - background: transparent; 113 - border: 0; 114 - padding: 0; 115 - margin: 0; 116 - cursor: pointer; 117 - font-family: var(--t-mono); 118 - font-size: 12px; 119 - font-weight: 600; 120 - color: var(--t-accent); 121 - } 110 + .actor { 111 + appearance: none; 112 + background: transparent; 113 + border: 0; 114 + padding: 0; 115 + margin: 0; 116 + cursor: pointer; 117 + font-family: var(--t-mono); 118 + font-size: 12px; 119 + font-weight: 600; 120 + color: var(--t-accent); 121 + } 122 122 123 - .verb { 124 - color: var(--t-text-secondary); 125 - } 123 + .verb { 124 + color: var(--t-text-secondary); 125 + } 126 126 127 - .target { 128 - font-family: var(--t-mono); 129 - font-size: 12px; 130 - font-weight: 500; 131 - color: var(--t-text-primary); 132 - } 127 + .target { 128 + font-family: var(--t-mono); 129 + font-size: 12px; 130 + font-weight: 500; 131 + color: var(--t-text-primary); 132 + } 133 133 134 - .activity-time { 135 - font-size: 11px; 136 - color: var(--t-text-muted); 137 - margin-top: 2px; 138 - } 134 + .activity-time { 135 + font-size: 11px; 136 + color: var(--t-text-muted); 137 + margin-top: 2px; 138 + } 139 139 </style>
+45 -45
apps/twisted/src/components/common/EmptyState.vue
··· 12 12 </template> 13 13 14 14 <script setup lang="ts"> 15 - import { IonIcon, IonButton } from "@ionic/vue"; 15 + import { IonIcon, IonButton } from "@ionic/vue"; 16 16 17 - defineProps<{ icon: string; title: string; message?: string; actionLabel?: string }>(); 17 + defineProps<{ icon: string; title: string; message?: string; actionLabel?: string }>(); 18 18 19 - const emit = defineEmits<{ action: [] }>(); 19 + const emit = defineEmits<{ action: [] }>(); 20 20 </script> 21 21 22 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 - } 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 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 - } 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 43 44 - .empty-icon { 45 - font-size: 28px; 46 - color: var(--t-accent); 47 - } 44 + .empty-icon { 45 + font-size: 28px; 46 + color: var(--t-accent); 47 + } 48 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 - } 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 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 - } 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 64 65 - .empty-action { 66 - --color: var(--t-accent); 67 - --border-color: var(--t-accent); 68 - margin-top: 6px; 69 - } 65 + .empty-action { 66 + --color: var(--t-accent); 67 + --border-color: var(--t-accent); 68 + margin-top: 6px; 69 + } 70 70 </style>
+53 -53
apps/twisted/src/components/common/ErrorBoundary.vue
··· 18 18 </template> 19 19 20 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"; 21 + import { ref, onErrorCaptured } from "vue"; 22 + import { IonIcon, IonButton } from "@ionic/vue"; 23 + import { alertCircleOutline, refreshOutline } from "ionicons/icons"; 24 24 25 - const error = ref<Error | null>(null); 25 + const error = ref<Error | null>(null); 26 26 27 - onErrorCaptured((err) => { 28 - error.value = err instanceof Error ? err : new Error(String(err)); 29 - return false; 30 - }); 27 + onErrorCaptured((err) => { 28 + error.value = err instanceof Error ? err : new Error(String(err)); 29 + return false; 30 + }); 31 31 32 - function retry() { 33 - error.value = null; 34 - } 32 + function retry() { 33 + error.value = null; 34 + } 35 35 </script> 36 36 37 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 - } 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 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 - } 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 58 59 - .error-icon { 60 - font-size: 28px; 61 - color: var(--t-red); 62 - } 59 + .error-icon { 60 + font-size: 28px; 61 + color: var(--t-red); 62 + } 63 63 64 - .error-title { 65 - font-size: 16px; 66 - font-weight: 600; 67 - color: var(--t-text-primary); 68 - margin: 0; 69 - } 64 + .error-title { 65 + font-size: 16px; 66 + font-weight: 600; 67 + color: var(--t-text-primary); 68 + margin: 0; 69 + } 70 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 - } 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 79 80 - .retry-btn { 81 - --color: var(--t-red); 82 - --border-color: var(--t-red); 83 - margin-top: 6px; 84 - } 80 + .retry-btn { 81 + --color: var(--t-red); 82 + --border-color: var(--t-red); 83 + margin-top: 6px; 84 + } 85 85 </style>
+127 -127
apps/twisted/src/components/common/RepoCard.vue
··· 25 25 </template> 26 26 27 27 <script setup lang="ts"> 28 - import { IonCard, IonCardContent, IonIcon } from "@ionic/vue"; 29 - import { starOutline } from "ionicons/icons"; 30 - import type { RepoSummary } from "@/domain/models/repo"; 28 + import { IonCard, IonCardContent, IonIcon } from "@ionic/vue"; 29 + import { starOutline } from "ionicons/icons"; 30 + import type { RepoSummary } from "@/domain/models/repo"; 31 31 32 - defineProps<{ repo: RepoSummary }>(); 33 - const emit = defineEmits<{ click: []; ownerClick: [] }>(); 32 + defineProps<{ repo: RepoSummary }>(); 33 + const emit = defineEmits<{ click: []; ownerClick: [] }>(); 34 34 35 - const LANG_COLORS: Record<string, string> = { 36 - TypeScript: "#3178c6", 37 - JavaScript: "#f7df1e", 38 - Go: "#00add8", 39 - Python: "#3572A5", 40 - Rust: "#dea584", 41 - Nix: "#7ebae4", 42 - Ruby: "#cc342d", 43 - CSS: "#563d7c", 44 - HTML: "#e34c26", 45 - Shell: "#89e051", 46 - Swift: "#F05138", 47 - Kotlin: "#A97BFF", 48 - Dart: "#00B4AB", 49 - }; 35 + const LANG_COLORS: Record<string, string> = { 36 + TypeScript: "#3178c6", 37 + JavaScript: "#f7df1e", 38 + Go: "#00add8", 39 + Python: "#3572A5", 40 + Rust: "#dea584", 41 + Nix: "#7ebae4", 42 + Ruby: "#cc342d", 43 + CSS: "#563d7c", 44 + HTML: "#e34c26", 45 + Shell: "#89e051", 46 + Swift: "#F05138", 47 + Kotlin: "#A97BFF", 48 + Dart: "#00B4AB", 49 + }; 50 50 51 - function langColor(lang: string): string { 52 - return LANG_COLORS[lang] ?? "var(--t-text-muted)"; 53 - } 51 + function langColor(lang: string): string { 52 + return LANG_COLORS[lang] ?? "var(--t-text-muted)"; 53 + } 54 54 55 - function formatCount(n: number): string { 56 - return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); 57 - } 55 + function formatCount(n: number): string { 56 + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); 57 + } 58 58 59 - function relativeTime(iso: string): string { 60 - const diff = Date.now() - new Date(iso).getTime(); 61 - const m = Math.floor(diff / 60000); 62 - const h = Math.floor(m / 60); 63 - const d = Math.floor(h / 24); 64 - if (d > 0) return `${d}d ago`; 65 - if (h > 0) return `${h}h ago`; 66 - if (m > 0) return `${m}m ago`; 67 - return "just now"; 68 - } 59 + function relativeTime(iso: string): string { 60 + const diff = Date.now() - new Date(iso).getTime(); 61 + const m = Math.floor(diff / 60000); 62 + const h = Math.floor(m / 60); 63 + const d = Math.floor(h / 24); 64 + if (d > 0) return `${d}d ago`; 65 + if (h > 0) return `${h}h ago`; 66 + if (m > 0) return `${m}m ago`; 67 + return "just now"; 68 + } 69 69 </script> 70 70 71 71 <style scoped> 72 - .repo-card { 73 - --background: var(--t-surface); 74 - margin: 6px 16px; 75 - border-radius: var(--t-radius-md); 76 - border: 1px solid var(--t-border); 77 - box-shadow: none; 78 - } 72 + .repo-card { 73 + --background: var(--t-surface); 74 + margin: 6px 16px; 75 + border-radius: var(--t-radius-md); 76 + border: 1px solid var(--t-border); 77 + box-shadow: none; 78 + } 79 79 80 - .card-body { 81 - padding: 14px 16px; 82 - } 80 + .card-body { 81 + padding: 14px 16px; 82 + } 83 83 84 - .repo-header { 85 - display: flex; 86 - align-items: center; 87 - gap: 0; 88 - margin-bottom: 6px; 89 - } 84 + .repo-header { 85 + display: flex; 86 + align-items: center; 87 + gap: 0; 88 + margin-bottom: 6px; 89 + } 90 90 91 - .repo-owner { 92 - appearance: none; 93 - background: transparent; 94 - border: 0; 95 - padding: 0; 96 - margin: 0; 97 - cursor: pointer; 98 - font-family: var(--t-mono); 99 - font-size: 13px; 100 - color: var(--t-text-secondary); 101 - line-height: 1.4; 102 - } 91 + .repo-owner { 92 + appearance: none; 93 + background: transparent; 94 + border: 0; 95 + padding: 0; 96 + margin: 0; 97 + cursor: pointer; 98 + font-family: var(--t-mono); 99 + font-size: 13px; 100 + color: var(--t-text-secondary); 101 + line-height: 1.4; 102 + } 103 103 104 - .repo-name { 105 - font-family: var(--t-mono); 106 - font-size: 13px; 107 - font-weight: 600; 108 - color: var(--t-accent); 109 - line-height: 1.4; 110 - flex: 1; 111 - } 104 + .repo-name { 105 + font-family: var(--t-mono); 106 + font-size: 13px; 107 + font-weight: 600; 108 + color: var(--t-accent); 109 + line-height: 1.4; 110 + flex: 1; 111 + } 112 112 113 - .stars { 114 - display: flex; 115 - align-items: center; 116 - gap: 3px; 117 - margin-left: auto; 118 - padding-left: 8px; 119 - flex-shrink: 0; 120 - } 113 + .stars { 114 + display: flex; 115 + align-items: center; 116 + gap: 3px; 117 + margin-left: auto; 118 + padding-left: 8px; 119 + flex-shrink: 0; 120 + } 121 121 122 - .star-icon { 123 - font-size: 12px; 124 - color: var(--t-amber); 125 - } 122 + .star-icon { 123 + font-size: 12px; 124 + color: var(--t-amber); 125 + } 126 126 127 - .star-count { 128 - font-family: var(--t-mono); 129 - font-size: 12px; 130 - color: var(--t-text-secondary); 131 - } 127 + .star-count { 128 + font-family: var(--t-mono); 129 + font-size: 12px; 130 + color: var(--t-text-secondary); 131 + } 132 132 133 - .repo-description { 134 - font-size: 13px; 135 - color: var(--t-text-secondary); 136 - margin: 0 0 10px; 137 - line-height: 1.5; 138 - display: -webkit-box; 139 - line-clamp: 2; 140 - -webkit-line-clamp: 2; 141 - -webkit-box-orient: vertical; 142 - overflow: hidden; 143 - } 133 + .repo-description { 134 + font-size: 13px; 135 + color: var(--t-text-secondary); 136 + margin: 0 0 10px; 137 + line-height: 1.5; 138 + display: -webkit-box; 139 + line-clamp: 2; 140 + -webkit-line-clamp: 2; 141 + -webkit-box-orient: vertical; 142 + overflow: hidden; 143 + } 144 144 145 - .repo-meta { 146 - display: flex; 147 - align-items: center; 148 - gap: 6px; 149 - } 145 + .repo-meta { 146 + display: flex; 147 + align-items: center; 148 + gap: 6px; 149 + } 150 150 151 - .lang-badge { 152 - display: flex; 153 - align-items: center; 154 - gap: 5px; 155 - font-size: 12px; 156 - color: var(--t-text-secondary); 157 - } 151 + .lang-badge { 152 + display: flex; 153 + align-items: center; 154 + gap: 5px; 155 + font-size: 12px; 156 + color: var(--t-text-secondary); 157 + } 158 158 159 - .lang-dot { 160 - display: inline-block; 161 - width: 10px; 162 - height: 10px; 163 - border-radius: 50%; 164 - flex-shrink: 0; 165 - } 159 + .lang-dot { 160 + display: inline-block; 161 + width: 10px; 162 + height: 10px; 163 + border-radius: 50%; 164 + flex-shrink: 0; 165 + } 166 166 167 - .meta-dot { 168 - color: var(--t-text-muted); 169 - font-size: 12px; 170 - } 167 + .meta-dot { 168 + color: var(--t-text-muted); 169 + font-size: 12px; 170 + } 171 171 172 - .updated-at { 173 - font-size: 12px; 174 - color: var(--t-text-muted); 175 - } 172 + .updated-at { 173 + font-size: 12px; 174 + color: var(--t-text-muted); 175 + } 176 176 </style>
+111 -111
apps/twisted/src/components/common/SkeletonLoader.vue
··· 37 37 </template> 38 38 39 39 <script setup lang="ts"> 40 - import { IonCard, IonCardContent, IonItem, IonLabel, IonSkeletonText } from "@ionic/vue"; 40 + import { IonCard, IonCardContent, IonItem, IonLabel, IonSkeletonText } from "@ionic/vue"; 41 41 42 - defineProps<{ variant: "card" | "list-item" | "profile" }>(); 42 + defineProps<{ variant: "card" | "list-item" | "profile" }>(); 43 43 </script> 44 44 45 45 <style scoped> 46 - /* shared */ 47 - .skel { 48 - border-radius: 4px; 49 - line-height: 1; 50 - } 46 + /* shared */ 47 + .skel { 48 + border-radius: 4px; 49 + line-height: 1; 50 + } 51 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 - } 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 60 61 - .card-body { 62 - padding: 14px 16px; 63 - } 61 + .card-body { 62 + padding: 14px 16px; 63 + } 64 64 65 - .row { 66 - display: flex; 67 - align-items: center; 68 - gap: 8px; 69 - } 65 + .row { 66 + display: flex; 67 + align-items: center; 68 + gap: 8px; 69 + } 70 70 71 - .space-between { 72 - justify-content: space-between; 73 - margin-bottom: 10px; 74 - } 71 + .space-between { 72 + justify-content: space-between; 73 + margin-bottom: 10px; 74 + } 75 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 - } 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 103 104 - /* list-item */ 105 - .skeleton-item { 106 - --background: transparent; 107 - --padding-start: 16px; 108 - --inner-padding-end: 16px; 109 - } 104 + /* list-item */ 105 + .skeleton-item { 106 + --background: transparent; 107 + --padding-start: 16px; 108 + --inner-padding-end: 16px; 109 + } 110 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 - } 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 127 128 - /* profile */ 129 - .skeleton-profile { 130 - display: flex; 131 - gap: 14px; 132 - padding: 16px; 133 - } 128 + /* profile */ 129 + .skeleton-profile { 130 + display: flex; 131 + gap: 14px; 132 + padding: 16px; 133 + } 134 134 135 - .skel-profile-avatar { 136 - width: 56px; 137 - height: 56px; 138 - border-radius: var(--t-radius-sm); 139 - flex-shrink: 0; 140 - } 135 + .skel-profile-avatar { 136 + width: 56px; 137 + height: 56px; 138 + border-radius: var(--t-radius-sm); 139 + flex-shrink: 0; 140 + } 141 141 142 - .profile-lines { 143 - flex: 1; 144 - display: flex; 145 - flex-direction: column; 146 - gap: 7px; 147 - padding-top: 4px; 148 - } 142 + .profile-lines { 143 + flex: 1; 144 + display: flex; 145 + flex-direction: column; 146 + gap: 7px; 147 + padding-top: 4px; 148 + } 149 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 - } 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 166 </style>
+104 -104
apps/twisted/src/components/common/UserCard.vue
··· 29 29 </template> 30 30 31 31 <script setup lang="ts"> 32 - import { IonCard, IonCardContent, IonAvatar } from "@ionic/vue"; 33 - import type { UserSummary } from "@/domain/models/user"; 32 + import { IonCard, IonCardContent, IonAvatar } from "@ionic/vue"; 33 + import type { UserSummary } from "@/domain/models/user"; 34 34 35 - defineProps<{ user: UserSummary }>(); 36 - const emit = defineEmits<{ click: [] }>(); 35 + defineProps<{ user: UserSummary }>(); 36 + const emit = defineEmits<{ click: [] }>(); 37 37 38 - const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 38 + const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 39 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 - } 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 45 46 - function initials(handle: string): string { 47 - const base = handle.split(".")[0]; 48 - return base.slice(0, 2).toUpperCase(); 49 - } 46 + function initials(handle: string): string { 47 + const base = handle.split(".")[0]; 48 + return base.slice(0, 2).toUpperCase(); 49 + } 50 50 51 - function formatCount(n: number): string { 52 - return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); 53 - } 51 + function formatCount(n: number): string { 52 + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); 53 + } 54 54 </script> 55 55 56 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 - } 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 64 65 - .card-body { 66 - padding: 14px 16px; 67 - } 65 + .card-body { 66 + padding: 14px 16px; 67 + } 68 68 69 - .user-row { 70 - display: flex; 71 - align-items: flex-start; 72 - gap: 12px; 73 - } 69 + .user-row { 70 + display: flex; 71 + align-items: flex-start; 72 + gap: 12px; 73 + } 74 74 75 - .avatar { 76 - width: 44px; 77 - height: 44px; 78 - flex-shrink: 0; 79 - border-radius: var(--t-radius-sm); 80 - overflow: hidden; 81 - } 75 + .avatar { 76 + width: 44px; 77 + height: 44px; 78 + flex-shrink: 0; 79 + border-radius: var(--t-radius-sm); 80 + overflow: hidden; 81 + } 82 82 83 - .avatar-image { 84 - width: 100%; 85 - height: 100%; 86 - object-fit: cover; 87 - display: block; 88 - } 83 + .avatar-image { 84 + width: 100%; 85 + height: 100%; 86 + object-fit: cover; 87 + display: block; 88 + } 89 89 90 - .avatar-fallback { 91 - width: 100%; 92 - height: 100%; 93 - display: flex; 94 - align-items: center; 95 - justify-content: center; 96 - font-family: var(--t-mono); 97 - font-size: 13px; 98 - font-weight: 700; 99 - color: #0d1117; 100 - border-radius: var(--t-radius-sm); 101 - } 90 + .avatar-fallback { 91 + width: 100%; 92 + height: 100%; 93 + display: flex; 94 + align-items: center; 95 + justify-content: center; 96 + font-family: var(--t-mono); 97 + font-size: 13px; 98 + font-weight: 700; 99 + color: #0d1117; 100 + border-radius: var(--t-radius-sm); 101 + } 102 102 103 - .user-info { 104 - flex: 1; 105 - min-width: 0; 106 - } 103 + .user-info { 104 + flex: 1; 105 + min-width: 0; 106 + } 107 107 108 - .user-handle { 109 - font-family: var(--t-mono); 110 - font-size: 13px; 111 - font-weight: 600; 112 - color: var(--t-accent); 113 - line-height: 1.3; 114 - white-space: nowrap; 115 - overflow: hidden; 116 - text-overflow: ellipsis; 117 - } 108 + .user-handle { 109 + font-family: var(--t-mono); 110 + font-size: 13px; 111 + font-weight: 600; 112 + color: var(--t-accent); 113 + line-height: 1.3; 114 + white-space: nowrap; 115 + overflow: hidden; 116 + text-overflow: ellipsis; 117 + } 118 118 119 - .user-display-name { 120 - font-size: 13px; 121 - font-weight: 500; 122 - color: var(--t-text-primary); 123 - margin-top: 1px; 124 - line-height: 1.3; 125 - } 119 + .user-display-name { 120 + font-size: 13px; 121 + font-weight: 500; 122 + color: var(--t-text-primary); 123 + margin-top: 1px; 124 + line-height: 1.3; 125 + } 126 126 127 - .user-bio { 128 - font-size: 12px; 129 - color: var(--t-text-secondary); 130 - margin: 4px 0 0; 131 - line-height: 1.4; 132 - display: -webkit-box; 133 - line-clamp: 2; 134 - -webkit-line-clamp: 2; 135 - -webkit-box-orient: vertical; 136 - overflow: hidden; 137 - } 127 + .user-bio { 128 + font-size: 12px; 129 + color: var(--t-text-secondary); 130 + margin: 4px 0 0; 131 + line-height: 1.4; 132 + display: -webkit-box; 133 + line-clamp: 2; 134 + -webkit-line-clamp: 2; 135 + -webkit-box-orient: vertical; 136 + overflow: hidden; 137 + } 138 138 139 - .user-stats { 140 - display: flex; 141 - gap: 14px; 142 - margin-top: 10px; 143 - padding-top: 10px; 144 - border-top: 1px solid var(--t-border); 145 - } 139 + .user-stats { 140 + display: flex; 141 + gap: 14px; 142 + margin-top: 10px; 143 + padding-top: 10px; 144 + border-top: 1px solid var(--t-border); 145 + } 146 146 147 - .stat { 148 - font-size: 12px; 149 - color: var(--t-text-muted); 150 - } 147 + .stat { 148 + font-size: 12px; 149 + color: var(--t-text-muted); 150 + } 151 151 152 - .stat strong { 153 - font-weight: 600; 154 - color: var(--t-text-secondary); 155 - } 152 + .stat strong { 153 + font-weight: 600; 154 + color: var(--t-text-secondary); 155 + } 156 156 </style>
+66 -66
apps/twisted/src/components/repo/CommentThread.vue
··· 14 14 </template> 15 15 16 16 <script setup lang="ts"> 17 - import type { IssueComment, PullRequestComment } from "@/domain/models/comment.js"; 17 + import type { IssueComment, PullRequestComment } from "@/domain/models/comment.js"; 18 18 19 - defineProps<{ comments: Array<IssueComment | PullRequestComment> }>(); 19 + defineProps<{ comments: Array<IssueComment | PullRequestComment> }>(); 20 20 21 - function relativeTime(iso: string): string { 22 - const timestamp = Date.parse(iso); 23 - if (Number.isNaN(timestamp)) return iso; 21 + function relativeTime(iso: string): string { 22 + const timestamp = Date.parse(iso); 23 + if (Number.isNaN(timestamp)) return iso; 24 24 25 - const diff = Date.now() - timestamp; 26 - const minutes = Math.floor(diff / 60_000); 27 - const hours = Math.floor(minutes / 60); 28 - const days = Math.floor(hours / 24); 25 + const diff = Date.now() - timestamp; 26 + const minutes = Math.floor(diff / 60_000); 27 + const hours = Math.floor(minutes / 60); 28 + const days = Math.floor(hours / 24); 29 29 30 - if (days > 0) return `${days}d ago`; 31 - if (hours > 0) return `${hours}h ago`; 32 - if (minutes > 0) return `${minutes}m ago`; 33 - return "just now"; 34 - } 30 + if (days > 0) return `${days}d ago`; 31 + if (hours > 0) return `${hours}h ago`; 32 + if (minutes > 0) return `${minutes}m ago`; 33 + return "just now"; 34 + } 35 35 36 - function commentStyle(depth: number) { 37 - return { marginLeft: `${Math.min(depth, 4) * 16}px` }; 38 - } 36 + function commentStyle(depth: number) { 37 + return { marginLeft: `${Math.min(depth, 4) * 16}px` }; 38 + } 39 39 </script> 40 40 41 41 <style scoped> 42 - .comment-thread { 43 - display: flex; 44 - flex-direction: column; 45 - gap: 12px; 46 - } 42 + .comment-thread { 43 + display: flex; 44 + flex-direction: column; 45 + gap: 12px; 46 + } 47 47 48 - .comment-card { 49 - background: var(--t-surface-raised); 50 - border: 1px solid var(--t-border); 51 - border-radius: var(--t-radius-md); 52 - padding: 12px 14px; 53 - } 48 + .comment-card { 49 + background: var(--t-surface-raised); 50 + border: 1px solid var(--t-border); 51 + border-radius: var(--t-radius-md); 52 + padding: 12px 14px; 53 + } 54 54 55 - .comment-head { 56 - display: flex; 57 - align-items: center; 58 - gap: 6px; 59 - flex-wrap: wrap; 60 - margin-bottom: 8px; 61 - font-size: 12px; 62 - color: var(--t-text-muted); 63 - } 55 + .comment-head { 56 + display: flex; 57 + align-items: center; 58 + gap: 6px; 59 + flex-wrap: wrap; 60 + margin-bottom: 8px; 61 + font-size: 12px; 62 + color: var(--t-text-muted); 63 + } 64 64 65 - .author { 66 - color: var(--t-accent); 67 - font-size: 11px; 68 - } 65 + .author { 66 + color: var(--t-accent); 67 + font-size: 11px; 68 + } 69 69 70 - .mono { 71 - font-family: var(--t-mono); 72 - } 70 + .mono { 71 + font-family: var(--t-mono); 72 + } 73 73 74 - .dot { 75 - color: var(--t-border-strong); 76 - } 74 + .dot { 75 + color: var(--t-border-strong); 76 + } 77 77 78 - .reply-pill { 79 - padding: 1px 6px; 80 - border-radius: 999px; 81 - background: var(--t-accent-dim); 82 - color: var(--t-accent); 83 - font-size: 10px; 84 - font-weight: 600; 85 - text-transform: uppercase; 86 - letter-spacing: 0.05em; 87 - } 78 + .reply-pill { 79 + padding: 1px 6px; 80 + border-radius: 999px; 81 + background: var(--t-accent-dim); 82 + color: var(--t-accent); 83 + font-size: 10px; 84 + font-weight: 600; 85 + text-transform: uppercase; 86 + letter-spacing: 0.05em; 87 + } 88 88 89 - .comment-body { 90 - margin: 0; 91 - white-space: pre-wrap; 92 - word-break: break-word; 93 - font-family: inherit; 94 - font-size: 13px; 95 - line-height: 1.55; 96 - color: var(--t-text-secondary); 97 - } 89 + .comment-body { 90 + margin: 0; 91 + white-space: pre-wrap; 92 + word-break: break-word; 93 + font-family: inherit; 94 + font-size: 13px; 95 + line-height: 1.55; 96 + color: var(--t-text-secondary); 97 + } 98 98 </style>
+63 -63
apps/twisted/src/components/repo/FileTreeItem.vue
··· 12 12 </template> 13 13 14 14 <script setup lang="ts"> 15 - import { computed } from "vue"; 16 - import { IonItem, IonLabel, IonIcon } from "@ionic/vue"; 17 - import { folderOpenOutline, documentTextOutline, gitBranchOutline, chevronForwardOutline } from "ionicons/icons"; 18 - import type { RepoFile } from "@/domain/models/repo.js"; 15 + import { computed } from "vue"; 16 + import { IonItem, IonLabel, IonIcon } from "@ionic/vue"; 17 + import { folderOpenOutline, documentTextOutline, gitBranchOutline, chevronForwardOutline } from "ionicons/icons"; 18 + import type { RepoFile } from "@/domain/models/repo.js"; 19 19 20 - const props = defineProps<{ file: RepoFile; lines?: "full" | "inset" | "none" }>(); 20 + const props = defineProps<{ file: RepoFile; lines?: "full" | "inset" | "none" }>(); 21 21 22 - const emit = defineEmits<{ click: [] }>(); 22 + const emit = defineEmits<{ click: [] }>(); 23 23 24 - const fileIcon = computed(() => { 25 - if (props.file.type === "dir") return folderOpenOutline; 26 - if (props.file.type === "submodule") return gitBranchOutline; 27 - return documentTextOutline; 28 - }); 24 + const fileIcon = computed(() => { 25 + if (props.file.type === "dir") return folderOpenOutline; 26 + if (props.file.type === "submodule") return gitBranchOutline; 27 + return documentTextOutline; 28 + }); 29 29 </script> 30 30 31 31 <style scoped> 32 - .file-item { 33 - --background: transparent; 34 - --padding-start: 16px; 35 - --inner-padding-end: 12px; 36 - --min-height: 46px; 37 - } 32 + .file-item { 33 + --background: transparent; 34 + --padding-start: 16px; 35 + --inner-padding-end: 12px; 36 + --min-height: 46px; 37 + } 38 38 39 - .file-icon { 40 - font-size: 17px; 41 - margin-right: 10px; 42 - flex-shrink: 0; 43 - } 39 + .file-icon { 40 + font-size: 17px; 41 + margin-right: 10px; 42 + flex-shrink: 0; 43 + } 44 44 45 - .file-icon.dir { 46 - color: var(--t-amber); 47 - } 45 + .file-icon.dir { 46 + color: var(--t-amber); 47 + } 48 48 49 - .file-icon.file { 50 - color: var(--t-text-muted); 51 - } 49 + .file-icon.file { 50 + color: var(--t-text-muted); 51 + } 52 52 53 - .file-icon.submodule { 54 - color: var(--t-purple); 55 - } 53 + .file-icon.submodule { 54 + color: var(--t-purple); 55 + } 56 56 57 - .file-label { 58 - display: flex; 59 - flex-direction: row; 60 - align-items: center; 61 - gap: 0; 62 - min-width: 0; 63 - } 57 + .file-label { 58 + display: flex; 59 + flex-direction: row; 60 + align-items: center; 61 + gap: 0; 62 + min-width: 0; 63 + } 64 64 65 - .file-name { 66 - font-family: var(--t-mono); 67 - font-size: 13px; 68 - font-weight: 500; 69 - color: var(--t-text-primary); 70 - white-space: nowrap; 71 - overflow: hidden; 72 - text-overflow: ellipsis; 73 - flex-shrink: 0; 74 - max-width: 45%; 75 - } 65 + .file-name { 66 + font-family: var(--t-mono); 67 + font-size: 13px; 68 + font-weight: 500; 69 + color: var(--t-text-primary); 70 + white-space: nowrap; 71 + overflow: hidden; 72 + text-overflow: ellipsis; 73 + flex-shrink: 0; 74 + max-width: 45%; 75 + } 76 76 77 - .commit-msg { 78 - font-size: 12px; 79 - color: var(--t-text-muted); 80 - white-space: nowrap; 81 - overflow: hidden; 82 - text-overflow: ellipsis; 83 - flex: 1; 84 - margin-left: 12px; 85 - } 77 + .commit-msg { 78 + font-size: 12px; 79 + color: var(--t-text-muted); 80 + white-space: nowrap; 81 + overflow: hidden; 82 + text-overflow: ellipsis; 83 + flex: 1; 84 + margin-left: 12px; 85 + } 86 86 87 - .chevron { 88 - font-size: 14px; 89 - color: var(--t-text-muted); 90 - flex-shrink: 0; 91 - } 87 + .chevron { 88 + font-size: 14px; 89 + color: var(--t-text-muted); 90 + flex-shrink: 0; 91 + } 92 92 </style>
+1 -1
apps/twisted/src/core/config/project.ts
··· 1 - const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? "http://localhost:8080/"; 1 + const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? "http://127.0.0.1:8080"; 2 2 3 3 export const twisterApiBaseUrl = rawTwisterApiBaseUrl.replace(/\/+$/, ""); 4 4 export const hasTwisterApi = twisterApiBaseUrl.length > 0;
+1 -5
apps/twisted/src/core/query/client.ts
··· 4 4 5 5 export const queryClient = new QueryClient({ 6 6 defaultOptions: { 7 - queries: { 8 - staleTime: isDev ? 0 : 5 * 60 * 1000, 9 - gcTime: isDev ? 0 : 10 * 60 * 1000, 10 - retry: isDev ? 0 : 2, 11 - }, 7 + queries: { staleTime: isDev ? 0 : 5 * 60 * 1000, gcTime: isDev ? 0 : 10 * 60 * 1000, retry: isDev ? 0 : 2 }, 12 8 }, 13 9 });
+261 -262
apps/twisted/src/features/profile/UserProfilePage.vue
··· 144 144 </template> 145 145 146 146 <script setup lang="ts"> 147 - import { ref, computed, watch } from "vue"; 148 - import { useRoute, useRouter } from "vue-router"; 149 - import { 150 - IonPage, 151 - IonHeader, 152 - IonToolbar, 153 - IonTitle, 154 - IonContent, 155 - IonButtons, 156 - IonBackButton, 157 - IonAvatar, 158 - IonIcon, 159 - IonSegment, 160 - IonSegmentButton, 161 - } from "@ionic/vue"; 162 - import { 163 - alertCircleOutline, 164 - locationOutline, 165 - personOutline, 166 - linkOutline, 167 - codeSlashOutline, 168 - peopleOutline, 169 - } from "ionicons/icons"; 170 - import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 171 - import EmptyState from "@/components/common/EmptyState.vue"; 172 - import RepoCard from "@/components/common/RepoCard.vue"; 173 - import UserCard from "@/components/common/UserCard.vue"; 174 - import RepoIssues from "@/features/repo/RepoIssues.vue"; 175 - import RepoPRs from "@/features/repo/RepoPRs.vue"; 176 - import UserStrings from "@/features/profile/UserStrings.vue"; 177 - import { 178 - useIdentity, 179 - useActorProfile, 180 - useUserRepos, 181 - useUserStrings, 182 - useUserIssues, 183 - useUserPullRequests, 184 - useUserFollowing, 185 - } from "@/services/tangled/queries.js"; 186 - import { useIndexedProfileSummary } from "@/services/project-api/queries.js"; 187 - import type { IssueSummary } from "@/domain/models/issue.js"; 188 - import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 189 - import type { RepoSummary } from "@/domain/models/repo.js"; 147 + import { ref, computed, watch } from "vue"; 148 + import { useRoute, useRouter } from "vue-router"; 149 + import { 150 + IonPage, 151 + IonHeader, 152 + IonToolbar, 153 + IonTitle, 154 + IonContent, 155 + IonButtons, 156 + IonBackButton, 157 + IonAvatar, 158 + IonIcon, 159 + IonSegment, 160 + IonSegmentButton, 161 + } from "@ionic/vue"; 162 + import { 163 + alertCircleOutline, 164 + locationOutline, 165 + personOutline, 166 + linkOutline, 167 + codeSlashOutline, 168 + peopleOutline, 169 + } from "ionicons/icons"; 170 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 171 + import EmptyState from "@/components/common/EmptyState.vue"; 172 + import RepoCard from "@/components/common/RepoCard.vue"; 173 + import UserCard from "@/components/common/UserCard.vue"; 174 + import RepoIssues from "@/features/repo/RepoIssues.vue"; 175 + import RepoPRs from "@/features/repo/RepoPRs.vue"; 176 + import UserStrings from "@/features/profile/UserStrings.vue"; 177 + import { 178 + useIdentity, 179 + useActorProfile, 180 + useUserRepos, 181 + useUserStrings, 182 + useUserIssues, 183 + useUserPullRequests, 184 + useUserFollowing, 185 + } from "@/services/tangled/queries.js"; 186 + import { useIndexedProfileSummary } from "@/services/project-api/queries.js"; 187 + import type { IssueSummary } from "@/domain/models/issue.js"; 188 + import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 189 + import type { RepoSummary } from "@/domain/models/repo.js"; 190 190 191 - const route = useRoute(); 192 - const router = useRouter(); 193 - const handle = computed(() => String(route.params.handle ?? "")); 194 - const section = ref<"repos" | "strings" | "issues" | "prs" | "following">("repos"); 191 + const route = useRoute(); 192 + const router = useRouter(); 193 + const handle = computed(() => String(route.params.handle ?? "")); 194 + const section = ref<"repos" | "strings" | "issues" | "prs" | "following">("repos"); 195 195 196 - const identity = useIdentity(handle); 197 - const did = computed(() => identity.data.value?.did ?? ""); 198 - const hasIdentity = computed(() => !!identity.data.value); 199 - const tabPrefix = computed(() => { 200 - if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 201 - if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 202 - return "/tabs/home"; 203 - }); 196 + const identity = useIdentity(handle); 197 + const did = computed(() => identity.data.value?.did ?? ""); 198 + const hasIdentity = computed(() => !!identity.data.value); 199 + const tabPrefix = computed(() => { 200 + if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 201 + if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 202 + return "/tabs/home"; 203 + }); 204 204 205 - const profileQuery = useActorProfile(handle, undefined, { enabled: hasIdentity }); 206 - const reposQuery = useUserRepos(handle, { enabled: hasIdentity }); 207 - const stringsQuery = useUserStrings(handle, { enabled: hasIdentity }); 208 - const issuesQuery = useUserIssues(handle, { enabled: hasIdentity }); 209 - const pullRequestsQuery = useUserPullRequests(handle, { enabled: hasIdentity }); 210 - const followingQuery = useUserFollowing(handle, { enabled: hasIdentity }); 211 - const indexedProfileSummaryQuery = useIndexedProfileSummary(did, { enabled: hasIdentity }); 205 + const profileQuery = useActorProfile(handle, undefined, { enabled: hasIdentity }); 206 + const reposQuery = useUserRepos(handle, { enabled: hasIdentity }); 207 + const stringsQuery = useUserStrings(handle, { enabled: hasIdentity }); 208 + const issuesQuery = useUserIssues(handle, { enabled: hasIdentity }); 209 + const pullRequestsQuery = useUserPullRequests(handle, { enabled: hasIdentity }); 210 + const followingQuery = useUserFollowing(handle, { enabled: hasIdentity }); 211 + const indexedProfileSummaryQuery = useIndexedProfileSummary(did, { enabled: hasIdentity }); 212 212 213 - const profile = computed(() => profileQuery.data.value); 214 - const repos = computed(() => reposQuery.data.value ?? []); 215 - const strings = computed(() => stringsQuery.data.value ?? []); 216 - const issues = computed(() => issuesQuery.data.value ?? []); 217 - const pullRequests = computed(() => pullRequestsQuery.data.value ?? []); 218 - const following = computed(() => followingQuery.data.value ?? []); 219 - const indexedProfileSummary = computed(() => indexedProfileSummaryQuery.data.value); 213 + const profile = computed(() => profileQuery.data.value); 214 + const repos = computed(() => reposQuery.data.value ?? []); 215 + const strings = computed(() => stringsQuery.data.value ?? []); 216 + const issues = computed(() => issuesQuery.data.value ?? []); 217 + const pullRequests = computed(() => pullRequestsQuery.data.value ?? []); 218 + const following = computed(() => followingQuery.data.value ?? []); 219 + const indexedProfileSummary = computed(() => indexedProfileSummaryQuery.data.value); 220 220 221 - const pinnedUris = computed(() => (profile.value as { pinnedRepos?: string[] } | undefined)?.pinnedRepos ?? []); 222 - const pinnedRepos = computed(() => repos.value.filter((r) => pinnedUris.value.includes(r.atUri))); 223 - const otherRepos = computed(() => repos.value.filter((repo) => !pinnedUris.value.includes(repo.atUri))); 224 - const stats = computed(() => { 225 - const values = [ 226 - { label: "repos", value: repos.value.length }, 227 - { label: "strings", value: strings.value.length }, 228 - { label: "issues", value: issues.value.length }, 229 - { label: "prs", value: pullRequests.value.length }, 230 - ]; 221 + const pinnedUris = computed(() => (profile.value as { pinnedRepos?: string[] } | undefined)?.pinnedRepos ?? []); 222 + const pinnedRepos = computed(() => repos.value.filter((r) => pinnedUris.value.includes(r.atUri))); 223 + const otherRepos = computed(() => repos.value.filter((repo) => !pinnedUris.value.includes(repo.atUri))); 224 + const stats = computed(() => { 225 + const values = [ 226 + { label: "repos", value: repos.value.length }, 227 + { label: "strings", value: strings.value.length }, 228 + { label: "issues", value: issues.value.length }, 229 + { label: "prs", value: pullRequests.value.length }, 230 + ]; 231 231 232 - if (indexedProfileSummary.value?.followerCount != null) { 233 - values.splice(1, 0, { label: "followers", value: indexedProfileSummary.value.followerCount }); 234 - } 232 + if (indexedProfileSummary.value?.followerCount != null) { 233 + values.splice(1, 0, { label: "followers", value: indexedProfileSummary.value.followerCount }); 234 + } 235 235 236 - values.splice( 237 - indexedProfileSummary.value?.followerCount != null ? 2 : 1, 238 - 0, 239 - { label: "following", value: indexedProfileSummary.value?.followingCount ?? following.value.length }, 240 - ); 236 + values.splice(indexedProfileSummary.value?.followerCount != null ? 2 : 1, 0, { 237 + label: "following", 238 + value: indexedProfileSummary.value?.followingCount ?? following.value.length, 239 + }); 241 240 242 - return values; 243 - }); 241 + return values; 242 + }); 244 243 245 - const isLoading = computed(() => identity.isPending.value || profileQuery.isPending.value); 246 - const isError = computed(() => identity.isError.value || profileQuery.isError.value); 247 - const errorMessage = computed(() => { 248 - const err = identity.error.value ?? profileQuery.error.value; 249 - return err instanceof Error ? err.message : "An unexpected error occurred."; 250 - }); 244 + const isLoading = computed(() => identity.isPending.value || profileQuery.isPending.value); 245 + const isError = computed(() => identity.isError.value || profileQuery.isError.value); 246 + const errorMessage = computed(() => { 247 + const err = identity.error.value ?? profileQuery.error.value; 248 + return err instanceof Error ? err.message : "An unexpected error occurred."; 249 + }); 251 250 252 - watch(handle, () => { 253 - section.value = "repos"; 254 - }); 251 + watch(handle, () => { 252 + section.value = "repos"; 253 + }); 255 254 256 - function navigateToRepo(repo: RepoSummary) { 257 - router.push(`${tabPrefix.value}/repo/${repo.ownerHandle}/${repo.name}`); 258 - } 255 + function navigateToRepo(repo: RepoSummary) { 256 + router.push(`${tabPrefix.value}/repo/${repo.ownerHandle}/${repo.name}`); 257 + } 259 258 260 - function navigateToUser(profileHandle: string) { 261 - router.push(`${tabPrefix.value}/user/${profileHandle}`); 262 - } 259 + function navigateToUser(profileHandle: string) { 260 + router.push(`${tabPrefix.value}/user/${profileHandle}`); 261 + } 263 262 264 - function navigateToIssue(issue: IssueSummary) { 265 - const repoName = repos.value.find((repo) => repo.atUri === issue.repoAtUri)?.name; 266 - if (!repoName) return; 267 - router.push(`${tabPrefix.value}/repo/${handle.value}/${repoName}/issues/${issue.rkey}`); 268 - } 263 + function navigateToIssue(issue: IssueSummary) { 264 + const repoName = repos.value.find((repo) => repo.atUri === issue.repoAtUri)?.name; 265 + if (!repoName) return; 266 + router.push(`${tabPrefix.value}/repo/${handle.value}/${repoName}/issues/${issue.rkey}`); 267 + } 269 268 270 - function navigateToPullRequest(pullRequest: PullRequestSummary) { 271 - const repoName = repos.value.find((repo) => repo.atUri === pullRequest.targetRepoAtUri)?.name; 272 - if (!repoName) return; 273 - router.push(`${tabPrefix.value}/repo/${handle.value}/${repoName}/pulls/${pullRequest.rkey}`); 274 - } 269 + function navigateToPullRequest(pullRequest: PullRequestSummary) { 270 + const repoName = repos.value.find((repo) => repo.atUri === pullRequest.targetRepoAtUri)?.name; 271 + if (!repoName) return; 272 + router.push(`${tabPrefix.value}/repo/${handle.value}/${repoName}/pulls/${pullRequest.rkey}`); 273 + } 275 274 276 - function displayLink(url: string): string { 277 - return url.trim().replace(/^[a-z]+:\/\//i, ""); 278 - } 275 + function displayLink(url: string): string { 276 + return url.trim().replace(/^[a-z]+:\/\//i, ""); 277 + } 279 278 280 - const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 279 + const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 281 280 282 - function avatarColor(h: string): string { 283 - let hash = 0; 284 - for (const ch of h) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff; 285 - return PALETTE[Math.abs(hash) % PALETTE.length]; 286 - } 281 + function avatarColor(h: string): string { 282 + let hash = 0; 283 + for (const ch of h) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff; 284 + return PALETTE[Math.abs(hash) % PALETTE.length]; 285 + } 287 286 288 - function initials(h: string): string { 289 - return h.split(".")[0].slice(0, 2).toUpperCase(); 290 - } 287 + function initials(h: string): string { 288 + return h.split(".")[0].slice(0, 2).toUpperCase(); 289 + } 291 290 </script> 292 291 293 292 <style scoped> 294 - .profile-title { 295 - font-family: var(--t-mono); 296 - font-size: 14px; 297 - } 293 + .profile-title { 294 + font-family: var(--t-mono); 295 + font-size: 14px; 296 + } 298 297 299 - .mono { 300 - font-family: var(--t-mono); 301 - } 298 + .mono { 299 + font-family: var(--t-mono); 300 + } 302 301 303 - .profile-header { 304 - display: flex; 305 - align-items: center; 306 - gap: 16px; 307 - padding: 20px 16px 12px; 308 - } 302 + .profile-header { 303 + display: flex; 304 + align-items: center; 305 + gap: 16px; 306 + padding: 20px 16px 12px; 307 + } 309 308 310 - .avatar { 311 - width: 64px; 312 - height: 64px; 313 - flex-shrink: 0; 314 - border-radius: var(--t-radius-md); 315 - overflow: hidden; 316 - } 309 + .avatar { 310 + width: 64px; 311 + height: 64px; 312 + flex-shrink: 0; 313 + border-radius: var(--t-radius-md); 314 + overflow: hidden; 315 + } 317 316 318 - .avatar-image { 319 - width: 100%; 320 - height: 100%; 321 - object-fit: cover; 322 - display: block; 323 - } 317 + .avatar-image { 318 + width: 100%; 319 + height: 100%; 320 + object-fit: cover; 321 + display: block; 322 + } 324 323 325 - .avatar-fallback { 326 - width: 100%; 327 - height: 100%; 328 - display: flex; 329 - align-items: center; 330 - justify-content: center; 331 - font-family: var(--t-mono); 332 - font-size: 18px; 333 - font-weight: 700; 334 - color: #0d1117; 335 - } 324 + .avatar-fallback { 325 + width: 100%; 326 + height: 100%; 327 + display: flex; 328 + align-items: center; 329 + justify-content: center; 330 + font-family: var(--t-mono); 331 + font-size: 18px; 332 + font-weight: 700; 333 + color: #0d1117; 334 + } 336 335 337 - .profile-info { 338 - flex: 1; 339 - min-width: 0; 340 - } 336 + .profile-info { 337 + flex: 1; 338 + min-width: 0; 339 + } 341 340 342 - .profile-handle { 343 - font-family: var(--t-mono); 344 - font-size: 15px; 345 - font-weight: 600; 346 - color: var(--t-accent); 347 - line-height: 1.3; 348 - } 341 + .profile-handle { 342 + font-family: var(--t-mono); 343 + font-size: 15px; 344 + font-weight: 600; 345 + color: var(--t-accent); 346 + line-height: 1.3; 347 + } 349 348 350 - .profile-name { 351 - font-size: 14px; 352 - font-weight: 500; 353 - color: var(--t-text-primary); 354 - margin-top: 2px; 355 - } 349 + .profile-name { 350 + font-size: 14px; 351 + font-weight: 500; 352 + color: var(--t-text-primary); 353 + margin-top: 2px; 354 + } 356 355 357 - .profile-bio { 358 - font-size: 14px; 359 - color: var(--t-text-secondary); 360 - line-height: 1.55; 361 - padding: 0 16px 12px; 362 - } 356 + .profile-bio { 357 + font-size: 14px; 358 + color: var(--t-text-secondary); 359 + line-height: 1.55; 360 + padding: 0 16px 12px; 361 + } 363 362 364 - .profile-meta { 365 - display: flex; 366 - flex-wrap: wrap; 367 - gap: 12px; 368 - padding: 0 16px 12px; 369 - } 363 + .profile-meta { 364 + display: flex; 365 + flex-wrap: wrap; 366 + gap: 12px; 367 + padding: 0 16px 12px; 368 + } 370 369 371 - .meta-item { 372 - display: flex; 373 - align-items: center; 374 - gap: 5px; 375 - font-size: 13px; 376 - color: var(--t-text-muted); 377 - } 370 + .meta-item { 371 + display: flex; 372 + align-items: center; 373 + gap: 5px; 374 + font-size: 13px; 375 + color: var(--t-text-muted); 376 + } 378 377 379 - .meta-icon { 380 - font-size: 14px; 381 - } 378 + .meta-icon { 379 + font-size: 14px; 380 + } 382 381 383 - .profile-links { 384 - display: flex; 385 - flex-direction: column; 386 - gap: 6px; 387 - padding: 0 16px 14px; 388 - } 382 + .profile-links { 383 + display: flex; 384 + flex-direction: column; 385 + gap: 6px; 386 + padding: 0 16px 14px; 387 + } 389 388 390 - .profile-link { 391 - display: flex; 392 - align-items: center; 393 - gap: 6px; 394 - font-size: 13px; 395 - color: var(--t-accent); 396 - text-decoration: none; 397 - } 389 + .profile-link { 390 + display: flex; 391 + align-items: center; 392 + gap: 6px; 393 + font-size: 13px; 394 + color: var(--t-accent); 395 + text-decoration: none; 396 + } 398 397 399 - .link-icon { 400 - font-size: 14px; 401 - flex-shrink: 0; 402 - } 398 + .link-icon { 399 + font-size: 14px; 400 + flex-shrink: 0; 401 + } 403 402 404 - .section-label { 405 - font-size: 11px; 406 - font-weight: 600; 407 - text-transform: uppercase; 408 - letter-spacing: 0.07em; 409 - color: var(--t-text-muted); 410 - margin: 16px 16px 8px; 411 - } 403 + .section-label { 404 + font-size: 11px; 405 + font-weight: 600; 406 + text-transform: uppercase; 407 + letter-spacing: 0.07em; 408 + color: var(--t-text-muted); 409 + margin: 16px 16px 8px; 410 + } 412 411 413 - .stats-row { 414 - display: flex; 415 - gap: 8px; 416 - padding: 0 16px 16px; 417 - overflow-x: auto; 418 - scrollbar-width: none; 419 - } 412 + .stats-row { 413 + display: flex; 414 + gap: 8px; 415 + padding: 0 16px 16px; 416 + overflow-x: auto; 417 + scrollbar-width: none; 418 + } 420 419 421 - .stats-row::-webkit-scrollbar { 422 - display: none; 423 - } 420 + .stats-row::-webkit-scrollbar { 421 + display: none; 422 + } 424 423 425 - .stat-pill { 426 - display: inline-flex; 427 - align-items: baseline; 428 - gap: 6px; 429 - padding: 10px 12px; 430 - border-radius: 999px; 431 - border: 1px solid var(--t-border); 432 - background: var(--t-surface-raised); 433 - flex-shrink: 0; 434 - } 424 + .stat-pill { 425 + display: inline-flex; 426 + align-items: baseline; 427 + gap: 6px; 428 + padding: 10px 12px; 429 + border-radius: 999px; 430 + border: 1px solid var(--t-border); 431 + background: var(--t-surface-raised); 432 + flex-shrink: 0; 433 + } 435 434 436 - .stat-value { 437 - font-family: var(--t-mono); 438 - font-size: 12px; 439 - font-weight: 700; 440 - color: var(--t-text-primary); 441 - } 435 + .stat-value { 436 + font-family: var(--t-mono); 437 + font-size: 12px; 438 + font-weight: 700; 439 + color: var(--t-text-primary); 440 + } 442 441 443 - .stat-label { 444 - font-size: 12px; 445 - color: var(--t-text-muted); 446 - text-transform: lowercase; 447 - } 442 + .stat-label { 443 + font-size: 12px; 444 + color: var(--t-text-muted); 445 + text-transform: lowercase; 446 + } 448 447 449 - .profile-segment { 450 - padding: 0 12px 8px; 451 - } 448 + .profile-segment { 449 + padding: 0 12px 8px; 450 + } 452 451 </style>
+79 -79
apps/twisted/src/features/profile/UserStrings.vue
··· 28 28 </template> 29 29 30 30 <script setup lang="ts"> 31 - import { IonItem, IonLabel, IonList, IonSpinner } from "@ionic/vue"; 32 - import { documentTextOutline } from "ionicons/icons"; 33 - import EmptyState from "@/components/common/EmptyState.vue"; 34 - import type { StringSummary } from "@/domain/models/string.js"; 31 + import { IonItem, IonLabel, IonList, IonSpinner } from "@ionic/vue"; 32 + import { documentTextOutline } from "ionicons/icons"; 33 + import EmptyState from "@/components/common/EmptyState.vue"; 34 + import type { StringSummary } from "@/domain/models/string.js"; 35 35 36 - defineProps<{ strings: StringSummary[]; isLoading?: boolean }>(); 36 + defineProps<{ strings: StringSummary[]; isLoading?: boolean }>(); 37 37 38 - function preview(contents: string): string { 39 - return contents.length > 280 ? `${contents.slice(0, 280)}...` : contents; 40 - } 38 + function preview(contents: string): string { 39 + return contents.length > 280 ? `${contents.slice(0, 280)}...` : contents; 40 + } 41 41 42 - function relativeTime(iso: string): string { 43 - const diff = Date.now() - new Date(iso).getTime(); 44 - const minutes = Math.floor(diff / 60000); 45 - const hours = Math.floor(minutes / 60); 46 - const days = Math.floor(hours / 24); 47 - if (days > 0) return `${days}d ago`; 48 - if (hours > 0) return `${hours}h ago`; 49 - if (minutes > 0) return `${minutes}m ago`; 50 - return "just now"; 51 - } 42 + function relativeTime(iso: string): string { 43 + const diff = Date.now() - new Date(iso).getTime(); 44 + const minutes = Math.floor(diff / 60000); 45 + const hours = Math.floor(minutes / 60); 46 + const days = Math.floor(hours / 24); 47 + if (days > 0) return `${days}d ago`; 48 + if (hours > 0) return `${hours}h ago`; 49 + if (minutes > 0) return `${minutes}m ago`; 50 + return "just now"; 51 + } 52 52 </script> 53 53 54 54 <style scoped> 55 - .strings-view { 56 - padding-bottom: 32px; 57 - } 55 + .strings-view { 56 + padding-bottom: 32px; 57 + } 58 58 59 - .loading-center { 60 - display: flex; 61 - justify-content: center; 62 - padding: 48px 0; 63 - } 59 + .loading-center { 60 + display: flex; 61 + justify-content: center; 62 + padding: 48px 0; 63 + } 64 64 65 - .string-list { 66 - background: transparent; 67 - padding: 0; 68 - } 65 + .string-list { 66 + background: transparent; 67 + padding: 0; 68 + } 69 69 70 - .string-item { 71 - --background: transparent; 72 - --padding-start: 16px; 73 - --padding-end: 16px; 74 - --inner-padding-end: 0; 75 - } 70 + .string-item { 71 + --background: transparent; 72 + --padding-start: 16px; 73 + --padding-end: 16px; 74 + --inner-padding-end: 0; 75 + } 76 76 77 - .string-label { 78 - white-space: normal; 79 - padding: 10px 0 14px; 80 - } 77 + .string-label { 78 + white-space: normal; 79 + padding: 10px 0 14px; 80 + } 81 81 82 - .string-head { 83 - display: flex; 84 - align-items: center; 85 - justify-content: space-between; 86 - gap: 12px; 87 - } 82 + .string-head { 83 + display: flex; 84 + align-items: center; 85 + justify-content: space-between; 86 + gap: 12px; 87 + } 88 88 89 - .string-file { 90 - font-size: 12px; 91 - font-weight: 600; 92 - color: var(--t-accent); 93 - } 89 + .string-file { 90 + font-size: 12px; 91 + font-weight: 600; 92 + color: var(--t-accent); 93 + } 94 94 95 - .mono { 96 - font-family: var(--t-mono); 97 - } 95 + .mono { 96 + font-family: var(--t-mono); 97 + } 98 98 99 - .string-time { 100 - font-size: 11px; 101 - color: var(--t-text-muted); 102 - flex-shrink: 0; 103 - } 99 + .string-time { 100 + font-size: 11px; 101 + color: var(--t-text-muted); 102 + flex-shrink: 0; 103 + } 104 104 105 - .string-description { 106 - margin: 6px 0 8px; 107 - font-size: 13px; 108 - line-height: 1.45; 109 - color: var(--t-text-secondary); 110 - } 105 + .string-description { 106 + margin: 6px 0 8px; 107 + font-size: 13px; 108 + line-height: 1.45; 109 + color: var(--t-text-secondary); 110 + } 111 111 112 - .string-preview { 113 - margin: 0; 114 - padding: 12px; 115 - border-radius: var(--t-radius-sm); 116 - border: 1px solid var(--t-border); 117 - background: var(--t-surface-raised); 118 - color: var(--t-text-primary); 119 - font-family: var(--t-mono); 120 - font-size: 12px; 121 - line-height: 1.45; 122 - overflow-x: auto; 123 - white-space: pre-wrap; 124 - word-break: break-word; 125 - } 112 + .string-preview { 113 + margin: 0; 114 + padding: 12px; 115 + border-radius: var(--t-radius-sm); 116 + border: 1px solid var(--t-border); 117 + background: var(--t-surface-raised); 118 + color: var(--t-text-primary); 119 + font-family: var(--t-mono); 120 + font-size: 12px; 121 + line-height: 1.45; 122 + overflow-x: auto; 123 + white-space: pre-wrap; 124 + word-break: break-word; 125 + } 126 126 </style>
+152 -160
apps/twisted/src/features/repo/IssueDetailPage.vue
··· 74 74 </template> 75 75 76 76 <script setup lang="ts"> 77 - import { computed } from "vue"; 78 - import { useRoute } from "vue-router"; 79 - import { 80 - IonPage, 81 - IonHeader, 82 - IonToolbar, 83 - IonTitle, 84 - IonContent, 85 - IonButtons, 86 - IonBackButton, 87 - IonBadge, 88 - IonIcon, 89 - } from "@ionic/vue"; 90 - import { alertCircleOutline, chatbubbleOutline, documentTextOutline } from "ionicons/icons"; 91 - import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 92 - import EmptyState from "@/components/common/EmptyState.vue"; 93 - import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 94 - import CommentThread from "@/components/repo/CommentThread.vue"; 95 - import { useRepoRecord, useIssueDetail, useIssueComments, useDefaultBranch } from "@/services/tangled/queries.js"; 96 - import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 77 + import { computed } from "vue"; 78 + import { useRoute } from "vue-router"; 79 + import { 80 + IonPage, 81 + IonHeader, 82 + IonToolbar, 83 + IonTitle, 84 + IonContent, 85 + IonButtons, 86 + IonBackButton, 87 + IonBadge, 88 + IonIcon, 89 + } from "@ionic/vue"; 90 + import { alertCircleOutline, chatbubbleOutline, documentTextOutline } from "ionicons/icons"; 91 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 92 + import EmptyState from "@/components/common/EmptyState.vue"; 93 + import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 94 + import CommentThread from "@/components/repo/CommentThread.vue"; 95 + import { useRepoRecord, useIssueDetail, useIssueComments, useDefaultBranch } from "@/services/tangled/queries.js"; 96 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 97 97 98 - const route = useRoute(); 99 - const owner = computed(() => String(route.params.owner ?? "")); 100 - const repoName = computed(() => String(route.params.repo ?? "")); 101 - const issueId = computed(() => String(route.params.issueId ?? "")); 98 + const route = useRoute(); 99 + const owner = computed(() => String(route.params.owner ?? "")); 100 + const repoName = computed(() => String(route.params.repo ?? "")); 101 + const issueId = computed(() => String(route.params.issueId ?? "")); 102 102 103 - const repoQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 104 - const branchQuery = useDefaultBranch(owner, repoName, { 105 - enabled: computed(() => !!owner.value && !!repoName.value), 106 - }); 107 - const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 108 - const markdownContext = computed<RepoAssetContext | undefined>(() => { 109 - if (!owner.value || !repoName.value || !branchQuery.data.value?.name) return undefined; 103 + const repoQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 104 + const branchQuery = useDefaultBranch(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 105 + const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 106 + const markdownContext = computed<RepoAssetContext | undefined>(() => { 107 + if (!owner.value || !repoName.value || !branchQuery.data.value?.name) return undefined; 110 108 111 - return { 112 - owner: owner.value, 113 - repo: repoName.value, 114 - branch: branchQuery.data.value.name, 115 - }; 116 - }); 109 + return { owner: owner.value, repo: repoName.value, branch: branchQuery.data.value.name }; 110 + }); 117 111 118 - const issueQuery = useIssueDetail(owner, issueId, { enabled: computed(() => !!owner.value && !!issueId.value) }); 119 - const commentsQuery = useIssueComments(owner, issueId, { enabled: computed(() => !!owner.value && !!issueId.value) }); 112 + const issueQuery = useIssueDetail(owner, issueId, { enabled: computed(() => !!owner.value && !!issueId.value) }); 113 + const commentsQuery = useIssueComments(owner, issueId, { enabled: computed(() => !!owner.value && !!issueId.value) }); 120 114 121 - const issue = computed(() => { 122 - const value = issueQuery.data.value; 123 - if (!value) return undefined; 124 - if (repoAtUri.value && value.repoAtUri !== repoAtUri.value) return undefined; 125 - return value; 126 - }); 115 + const issue = computed(() => { 116 + const value = issueQuery.data.value; 117 + if (!value) return undefined; 118 + if (repoAtUri.value && value.repoAtUri !== repoAtUri.value) return undefined; 119 + return value; 120 + }); 127 121 128 - const comments = computed(() => commentsQuery.data.value ?? []); 129 - const isLoading = computed( 130 - () => repoQuery.isPending.value || issueQuery.isPending.value || commentsQuery.isPending.value, 131 - ); 132 - const isError = computed( 133 - () => repoQuery.isError.value || issueQuery.isError.value || commentsQuery.isError.value, 134 - ); 135 - const errorMessage = computed(() => { 136 - const error = repoQuery.error.value ?? issueQuery.error.value ?? commentsQuery.error.value; 137 - return error instanceof Error ? error.message : "An unexpected error occurred."; 138 - }); 122 + const comments = computed(() => commentsQuery.data.value ?? []); 123 + const isLoading = computed( 124 + () => repoQuery.isPending.value || issueQuery.isPending.value || commentsQuery.isPending.value, 125 + ); 126 + const isError = computed(() => repoQuery.isError.value || issueQuery.isError.value || commentsQuery.isError.value); 127 + const errorMessage = computed(() => { 128 + const error = repoQuery.error.value ?? issueQuery.error.value ?? commentsQuery.error.value; 129 + return error instanceof Error ? error.message : "An unexpected error occurred."; 130 + }); 139 131 140 - const tabPrefix = computed(() => { 141 - if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 142 - if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 143 - return "/tabs/home"; 144 - }); 132 + const tabPrefix = computed(() => { 133 + if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 134 + if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 135 + return "/tabs/home"; 136 + }); 145 137 146 - const backHref = computed(() => `${tabPrefix.value}/repo/${owner.value}/${repoName.value}?tab=issues`); 138 + const backHref = computed(() => `${tabPrefix.value}/repo/${owner.value}/${repoName.value}?tab=issues`); 147 139 148 - function relativeTime(iso: string): string { 149 - const timestamp = Date.parse(iso); 150 - if (Number.isNaN(timestamp)) return iso; 140 + function relativeTime(iso: string): string { 141 + const timestamp = Date.parse(iso); 142 + if (Number.isNaN(timestamp)) return iso; 151 143 152 - const diff = Date.now() - timestamp; 153 - const minutes = Math.floor(diff / 60_000); 154 - const hours = Math.floor(minutes / 60); 155 - const days = Math.floor(hours / 24); 144 + const diff = Date.now() - timestamp; 145 + const minutes = Math.floor(diff / 60_000); 146 + const hours = Math.floor(minutes / 60); 147 + const days = Math.floor(hours / 24); 156 148 157 - if (days > 0) return `${days}d ago`; 158 - if (hours > 0) return `${hours}h ago`; 159 - if (minutes > 0) return `${minutes}m ago`; 160 - return "just now"; 161 - } 149 + if (days > 0) return `${days}d ago`; 150 + if (hours > 0) return `${hours}h ago`; 151 + if (minutes > 0) return `${minutes}m ago`; 152 + return "just now"; 153 + } 162 154 </script> 163 155 164 156 <style scoped> 165 - .detail-title { 166 - font-family: var(--t-mono); 167 - font-size: 14px; 168 - } 157 + .detail-title { 158 + font-family: var(--t-mono); 159 + font-size: 14px; 160 + } 169 161 170 - .detail-shell { 171 - padding: 18px 16px 32px; 172 - } 162 + .detail-shell { 163 + padding: 18px 16px 32px; 164 + } 173 165 174 - .hero { 175 - margin-bottom: 24px; 176 - } 166 + .hero { 167 + margin-bottom: 24px; 168 + } 177 169 178 - .hero-top { 179 - display: flex; 180 - align-items: center; 181 - gap: 10px; 182 - flex-wrap: wrap; 183 - margin-bottom: 10px; 184 - } 170 + .hero-top { 171 + display: flex; 172 + align-items: center; 173 + gap: 10px; 174 + flex-wrap: wrap; 175 + margin-bottom: 10px; 176 + } 185 177 186 - .issue-key, 187 - .mono { 188 - font-family: var(--t-mono); 189 - } 178 + .issue-key, 179 + .mono { 180 + font-family: var(--t-mono); 181 + } 190 182 191 - .issue-key { 192 - font-size: 11px; 193 - color: var(--t-text-muted); 194 - } 183 + .issue-key { 184 + font-size: 11px; 185 + color: var(--t-text-muted); 186 + } 195 187 196 - .hero-title { 197 - margin: 0 0 8px; 198 - font-size: 22px; 199 - line-height: 1.2; 200 - color: var(--t-text-primary); 201 - } 188 + .hero-title { 189 + margin: 0 0 8px; 190 + font-size: 22px; 191 + line-height: 1.2; 192 + color: var(--t-text-primary); 193 + } 202 194 203 - .hero-meta { 204 - display: flex; 205 - align-items: center; 206 - gap: 6px; 207 - flex-wrap: wrap; 208 - font-size: 13px; 209 - color: var(--t-text-muted); 210 - } 195 + .hero-meta { 196 + display: flex; 197 + align-items: center; 198 + gap: 6px; 199 + flex-wrap: wrap; 200 + font-size: 13px; 201 + color: var(--t-text-muted); 202 + } 211 203 212 - .meta-accent { 213 - color: var(--t-accent); 214 - font-size: 12px; 215 - } 204 + .meta-accent { 205 + color: var(--t-accent); 206 + font-size: 12px; 207 + } 216 208 217 - .sep { 218 - color: var(--t-border-strong); 219 - } 209 + .sep { 210 + color: var(--t-border-strong); 211 + } 220 212 221 - .meta-icon { 222 - font-size: 12px; 223 - } 213 + .meta-icon { 214 + font-size: 12px; 215 + } 224 216 225 - .section { 226 - margin-top: 20px; 227 - } 217 + .section { 218 + margin-top: 20px; 219 + } 228 220 229 - .section-head { 230 - display: flex; 231 - align-items: center; 232 - justify-content: space-between; 233 - margin-bottom: 10px; 234 - } 221 + .section-head { 222 + display: flex; 223 + align-items: center; 224 + justify-content: space-between; 225 + margin-bottom: 10px; 226 + } 235 227 236 - .section-head h2 { 237 - font-size: 11px; 238 - font-weight: 600; 239 - text-transform: uppercase; 240 - letter-spacing: 0.07em; 241 - color: var(--t-text-muted); 242 - margin: 0; 243 - } 228 + .section-head h2 { 229 + font-size: 11px; 230 + font-weight: 600; 231 + text-transform: uppercase; 232 + letter-spacing: 0.07em; 233 + color: var(--t-text-muted); 234 + margin: 0; 235 + } 244 236 245 - .section-count { 246 - font-size: 12px; 247 - color: var(--t-text-muted); 248 - } 237 + .section-count { 238 + font-size: 12px; 239 + color: var(--t-text-muted); 240 + } 249 241 250 - .state-badge { 251 - font-size: 11px; 252 - font-weight: 600; 253 - border-radius: 999px; 254 - padding: 3px 8px; 255 - text-transform: capitalize; 256 - } 242 + .state-badge { 243 + font-size: 11px; 244 + font-weight: 600; 245 + border-radius: 999px; 246 + padding: 3px 8px; 247 + text-transform: capitalize; 248 + } 257 249 258 - .state-badge.open { 259 - --background: var(--t-green-dim); 260 - --color: var(--t-green); 261 - } 250 + .state-badge.open { 251 + --background: var(--t-green-dim); 252 + --color: var(--t-green); 253 + } 262 254 263 - .state-badge.closed { 264 - --background: transparent; 265 - --color: var(--t-text-muted); 266 - border: 1px solid var(--t-border-strong); 267 - } 255 + .state-badge.closed { 256 + --background: transparent; 257 + --color: var(--t-text-muted); 258 + border: 1px solid var(--t-border-strong); 259 + } 268 260 </style>
+180 -188
apps/twisted/src/features/repo/PullRequestDetailPage.vue
··· 83 83 </template> 84 84 85 85 <script setup lang="ts"> 86 - import { computed } from "vue"; 87 - import { useRoute } from "vue-router"; 88 - import { 89 - IonPage, 90 - IonHeader, 91 - IonToolbar, 92 - IonTitle, 93 - IonContent, 94 - IonButtons, 95 - IonBackButton, 96 - IonBadge, 97 - IonIcon, 98 - } from "@ionic/vue"; 99 - import { alertCircleOutline, chatbubbleOutline, documentTextOutline } from "ionicons/icons"; 100 - import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 101 - import EmptyState from "@/components/common/EmptyState.vue"; 102 - import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 103 - import CommentThread from "@/components/repo/CommentThread.vue"; 104 - import { 105 - useRepoRecord, 106 - useDefaultBranch, 107 - usePullRequestDetail, 108 - usePullRequestComments, 109 - } from "@/services/tangled/queries.js"; 110 - import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 86 + import { computed } from "vue"; 87 + import { useRoute } from "vue-router"; 88 + import { 89 + IonPage, 90 + IonHeader, 91 + IonToolbar, 92 + IonTitle, 93 + IonContent, 94 + IonButtons, 95 + IonBackButton, 96 + IonBadge, 97 + IonIcon, 98 + } from "@ionic/vue"; 99 + import { alertCircleOutline, chatbubbleOutline, documentTextOutline } from "ionicons/icons"; 100 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 101 + import EmptyState from "@/components/common/EmptyState.vue"; 102 + import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 103 + import CommentThread from "@/components/repo/CommentThread.vue"; 104 + import { 105 + useRepoRecord, 106 + useDefaultBranch, 107 + usePullRequestDetail, 108 + usePullRequestComments, 109 + } from "@/services/tangled/queries.js"; 110 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 111 111 112 - const route = useRoute(); 113 - const owner = computed(() => String(route.params.owner ?? "")); 114 - const repoName = computed(() => String(route.params.repo ?? "")); 115 - const pullId = computed(() => String(route.params.pullId ?? "")); 112 + const route = useRoute(); 113 + const owner = computed(() => String(route.params.owner ?? "")); 114 + const repoName = computed(() => String(route.params.repo ?? "")); 115 + const pullId = computed(() => String(route.params.pullId ?? "")); 116 116 117 - const repoQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 118 - const branchQuery = useDefaultBranch(owner, repoName, { 119 - enabled: computed(() => !!owner.value && !!repoName.value), 120 - }); 121 - const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 122 - const markdownContext = computed<RepoAssetContext | undefined>(() => { 123 - if (!owner.value || !repoName.value || !branchQuery.data.value?.name) return undefined; 117 + const repoQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 118 + const branchQuery = useDefaultBranch(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 119 + const repoAtUri = computed(() => repoQuery.data.value?.atUri ?? ""); 120 + const markdownContext = computed<RepoAssetContext | undefined>(() => { 121 + if (!owner.value || !repoName.value || !branchQuery.data.value?.name) return undefined; 124 122 125 - return { 126 - owner: owner.value, 127 - repo: repoName.value, 128 - branch: branchQuery.data.value.name, 129 - }; 130 - }); 123 + return { owner: owner.value, repo: repoName.value, branch: branchQuery.data.value.name }; 124 + }); 131 125 132 - const pullQuery = usePullRequestDetail(owner, pullId, { enabled: computed(() => !!owner.value && !!pullId.value) }); 133 - const commentsQuery = usePullRequestComments(owner, pullId, { 134 - enabled: computed(() => !!owner.value && !!pullId.value), 135 - }); 126 + const pullQuery = usePullRequestDetail(owner, pullId, { enabled: computed(() => !!owner.value && !!pullId.value) }); 127 + const commentsQuery = usePullRequestComments(owner, pullId, { 128 + enabled: computed(() => !!owner.value && !!pullId.value), 129 + }); 136 130 137 - const pullRequest = computed(() => { 138 - const value = pullQuery.data.value; 139 - if (!value) return undefined; 140 - if (repoAtUri.value && value.targetRepoAtUri !== repoAtUri.value) return undefined; 141 - return value; 142 - }); 131 + const pullRequest = computed(() => { 132 + const value = pullQuery.data.value; 133 + if (!value) return undefined; 134 + if (repoAtUri.value && value.targetRepoAtUri !== repoAtUri.value) return undefined; 135 + return value; 136 + }); 143 137 144 - const comments = computed(() => commentsQuery.data.value ?? []); 145 - const isLoading = computed( 146 - () => repoQuery.isPending.value || pullQuery.isPending.value || commentsQuery.isPending.value, 147 - ); 148 - const isError = computed( 149 - () => repoQuery.isError.value || pullQuery.isError.value || commentsQuery.isError.value, 150 - ); 151 - const errorMessage = computed(() => { 152 - const error = repoQuery.error.value ?? pullQuery.error.value ?? commentsQuery.error.value; 153 - return error instanceof Error ? error.message : "An unexpected error occurred."; 154 - }); 138 + const comments = computed(() => commentsQuery.data.value ?? []); 139 + const isLoading = computed( 140 + () => repoQuery.isPending.value || pullQuery.isPending.value || commentsQuery.isPending.value, 141 + ); 142 + const isError = computed(() => repoQuery.isError.value || pullQuery.isError.value || commentsQuery.isError.value); 143 + const errorMessage = computed(() => { 144 + const error = repoQuery.error.value ?? pullQuery.error.value ?? commentsQuery.error.value; 145 + return error instanceof Error ? error.message : "An unexpected error occurred."; 146 + }); 155 147 156 - const tabPrefix = computed(() => { 157 - if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 158 - if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 159 - return "/tabs/home"; 160 - }); 148 + const tabPrefix = computed(() => { 149 + if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 150 + if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 151 + return "/tabs/home"; 152 + }); 161 153 162 - const backHref = computed(() => `${tabPrefix.value}/repo/${owner.value}/${repoName.value}?tab=prs`); 154 + const backHref = computed(() => `${tabPrefix.value}/repo/${owner.value}/${repoName.value}?tab=prs`); 163 155 164 - function relativeTime(iso: string): string { 165 - const timestamp = Date.parse(iso); 166 - if (Number.isNaN(timestamp)) return iso; 156 + function relativeTime(iso: string): string { 157 + const timestamp = Date.parse(iso); 158 + if (Number.isNaN(timestamp)) return iso; 167 159 168 - const diff = Date.now() - timestamp; 169 - const minutes = Math.floor(diff / 60_000); 170 - const hours = Math.floor(minutes / 60); 171 - const days = Math.floor(hours / 24); 160 + const diff = Date.now() - timestamp; 161 + const minutes = Math.floor(diff / 60_000); 162 + const hours = Math.floor(minutes / 60); 163 + const days = Math.floor(hours / 24); 172 164 173 - if (days > 0) return `${days}d ago`; 174 - if (hours > 0) return `${hours}h ago`; 175 - if (minutes > 0) return `${minutes}m ago`; 176 - return "just now"; 177 - } 165 + if (days > 0) return `${days}d ago`; 166 + if (hours > 0) return `${hours}h ago`; 167 + if (minutes > 0) return `${minutes}m ago`; 168 + return "just now"; 169 + } 178 170 </script> 179 171 180 172 <style scoped> 181 - .detail-title { 182 - font-family: var(--t-mono); 183 - font-size: 14px; 184 - } 173 + .detail-title { 174 + font-family: var(--t-mono); 175 + font-size: 14px; 176 + } 185 177 186 - .detail-shell { 187 - padding: 18px 16px 32px; 188 - } 178 + .detail-shell { 179 + padding: 18px 16px 32px; 180 + } 189 181 190 - .hero { 191 - margin-bottom: 24px; 192 - } 182 + .hero { 183 + margin-bottom: 24px; 184 + } 193 185 194 - .hero-top { 195 - display: flex; 196 - align-items: center; 197 - gap: 10px; 198 - flex-wrap: wrap; 199 - margin-bottom: 10px; 200 - } 186 + .hero-top { 187 + display: flex; 188 + align-items: center; 189 + gap: 10px; 190 + flex-wrap: wrap; 191 + margin-bottom: 10px; 192 + } 201 193 202 - .pr-key, 203 - .mono { 204 - font-family: var(--t-mono); 205 - } 194 + .pr-key, 195 + .mono { 196 + font-family: var(--t-mono); 197 + } 206 198 207 - .pr-key { 208 - font-size: 11px; 209 - color: var(--t-text-muted); 210 - } 199 + .pr-key { 200 + font-size: 11px; 201 + color: var(--t-text-muted); 202 + } 211 203 212 - .hero-title { 213 - margin: 0 0 8px; 214 - font-size: 22px; 215 - line-height: 1.2; 216 - color: var(--t-text-primary); 217 - } 204 + .hero-title { 205 + margin: 0 0 8px; 206 + font-size: 22px; 207 + line-height: 1.2; 208 + color: var(--t-text-primary); 209 + } 218 210 219 - .hero-meta { 220 - display: flex; 221 - align-items: center; 222 - gap: 6px; 223 - flex-wrap: wrap; 224 - font-size: 13px; 225 - color: var(--t-text-muted); 226 - margin-bottom: 10px; 227 - } 211 + .hero-meta { 212 + display: flex; 213 + align-items: center; 214 + gap: 6px; 215 + flex-wrap: wrap; 216 + font-size: 13px; 217 + color: var(--t-text-muted); 218 + margin-bottom: 10px; 219 + } 228 220 229 - .meta-accent { 230 - color: var(--t-accent); 231 - font-size: 12px; 232 - } 221 + .meta-accent { 222 + color: var(--t-accent); 223 + font-size: 12px; 224 + } 233 225 234 - .sep { 235 - color: var(--t-border-strong); 236 - } 226 + .sep { 227 + color: var(--t-border-strong); 228 + } 237 229 238 - .meta-icon { 239 - font-size: 12px; 240 - } 230 + .meta-icon { 231 + font-size: 12px; 232 + } 241 233 242 - .branch-row { 243 - display: inline-flex; 244 - align-items: center; 245 - gap: 8px; 246 - padding: 6px 10px; 247 - border-radius: 999px; 248 - background: var(--t-surface-raised); 249 - border: 1px solid var(--t-border); 250 - } 234 + .branch-row { 235 + display: inline-flex; 236 + align-items: center; 237 + gap: 8px; 238 + padding: 6px 10px; 239 + border-radius: 999px; 240 + background: var(--t-surface-raised); 241 + border: 1px solid var(--t-border); 242 + } 251 243 252 - .branch { 253 - font-size: 11px; 254 - color: var(--t-text-secondary); 255 - } 244 + .branch { 245 + font-size: 11px; 246 + color: var(--t-text-secondary); 247 + } 256 248 257 - .branch-arrow { 258 - color: var(--t-border-strong); 259 - } 249 + .branch-arrow { 250 + color: var(--t-border-strong); 251 + } 260 252 261 - .section { 262 - margin-top: 20px; 263 - } 253 + .section { 254 + margin-top: 20px; 255 + } 264 256 265 - .section-head { 266 - display: flex; 267 - align-items: center; 268 - justify-content: space-between; 269 - margin-bottom: 10px; 270 - } 257 + .section-head { 258 + display: flex; 259 + align-items: center; 260 + justify-content: space-between; 261 + margin-bottom: 10px; 262 + } 271 263 272 - .section-head h2 { 273 - font-size: 11px; 274 - font-weight: 600; 275 - text-transform: uppercase; 276 - letter-spacing: 0.07em; 277 - color: var(--t-text-muted); 278 - margin: 0; 279 - } 264 + .section-head h2 { 265 + font-size: 11px; 266 + font-weight: 600; 267 + text-transform: uppercase; 268 + letter-spacing: 0.07em; 269 + color: var(--t-text-muted); 270 + margin: 0; 271 + } 280 272 281 - .section-count { 282 - font-size: 12px; 283 - color: var(--t-text-muted); 284 - } 273 + .section-count { 274 + font-size: 12px; 275 + color: var(--t-text-muted); 276 + } 285 277 286 - .status-badge { 287 - font-size: 11px; 288 - font-weight: 600; 289 - border-radius: 999px; 290 - padding: 3px 8px; 291 - text-transform: capitalize; 292 - } 278 + .status-badge { 279 + font-size: 11px; 280 + font-weight: 600; 281 + border-radius: 999px; 282 + padding: 3px 8px; 283 + text-transform: capitalize; 284 + } 293 285 294 - .status-badge.open { 295 - --background: var(--t-accent-dim); 296 - --color: var(--t-accent); 297 - } 286 + .status-badge.open { 287 + --background: var(--t-accent-dim); 288 + --color: var(--t-accent); 289 + } 298 290 299 - .status-badge.merged { 300 - --background: rgba(167, 139, 250, 0.1); 301 - --color: var(--t-purple); 302 - } 291 + .status-badge.merged { 292 + --background: rgba(167, 139, 250, 0.1); 293 + --color: var(--t-purple); 294 + } 303 295 304 - .status-badge.closed { 305 - --background: transparent; 306 - --color: var(--t-text-muted); 307 - border: 1px solid var(--t-border-strong); 308 - } 296 + .status-badge.closed { 297 + --background: transparent; 298 + --color: var(--t-text-muted); 299 + border: 1px solid var(--t-border-strong); 300 + } 309 301 </style>
+117 -126
apps/twisted/src/features/repo/RepoDetailPage.vue
··· 43 43 :repo="repo" 44 44 :commits="commits" 45 45 :markdown-context="markdownContext" /> 46 - <RepoFiles 47 - v-else-if="segment === 'files'" 48 - :owner="owner" 49 - :repo="repoName" 50 - :branch="defaultBranch" /> 46 + <RepoFiles v-else-if="segment === 'files'" :owner="owner" :repo="repoName" :branch="defaultBranch" /> 51 47 <RepoIssues 52 48 v-else-if="segment === 'issues'" 53 49 :issues="issues" ··· 64 60 </template> 65 61 66 62 <script setup lang="ts"> 67 - import { ref, computed, watch } from "vue"; 68 - import { useRoute, useRouter } from "vue-router"; 69 - import { 70 - IonPage, 71 - IonHeader, 72 - IonToolbar, 73 - IonTitle, 74 - IonContent, 75 - IonButtons, 76 - IonBackButton, 77 - IonSegment, 78 - IonSegmentButton, 79 - } from "@ionic/vue"; 80 - import { alertCircleOutline } from "ionicons/icons"; 81 - import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 82 - import EmptyState from "@/components/common/EmptyState.vue"; 83 - import RepoOverview from "./RepoOverview.vue"; 84 - import RepoFiles from "./RepoFiles.vue"; 85 - import RepoIssues from "./RepoIssues.vue"; 86 - import RepoPRs from "./RepoPRs.vue"; 87 - import { 88 - useRepoRecord, 89 - useDefaultBranch, 90 - useRepoBlob, 91 - useRepoLanguages, 92 - useRepoLog, 93 - useRepoIssues, 94 - useRepoPRs, 95 - } from "@/services/tangled/queries.js"; 96 - import { useRepoStarCount } from "@/services/constellation/queries.js"; 97 - import type { RepoDetail } from "@/domain/models/repo.js"; 98 - import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 63 + import { ref, computed, watch } from "vue"; 64 + import { useRoute, useRouter } from "vue-router"; 65 + import { 66 + IonPage, 67 + IonHeader, 68 + IonToolbar, 69 + IonTitle, 70 + IonContent, 71 + IonButtons, 72 + IonBackButton, 73 + IonSegment, 74 + IonSegmentButton, 75 + } from "@ionic/vue"; 76 + import { alertCircleOutline } from "ionicons/icons"; 77 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 78 + import EmptyState from "@/components/common/EmptyState.vue"; 79 + import RepoOverview from "./RepoOverview.vue"; 80 + import RepoFiles from "./RepoFiles.vue"; 81 + import RepoIssues from "./RepoIssues.vue"; 82 + import RepoPRs from "./RepoPRs.vue"; 83 + import { 84 + useRepoRecord, 85 + useDefaultBranch, 86 + useRepoBlob, 87 + useRepoLanguages, 88 + useRepoLog, 89 + useRepoIssues, 90 + useRepoPRs, 91 + } from "@/services/tangled/queries.js"; 92 + import { useRepoStarCount } from "@/services/constellation/queries.js"; 93 + import type { RepoDetail } from "@/domain/models/repo.js"; 94 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 99 95 100 - const route = useRoute(); 101 - const router = useRouter(); 102 - const owner = computed(() => String(route.params.owner ?? "")); 103 - const repoName = computed(() => String(route.params.repo ?? "")); 96 + const route = useRoute(); 97 + const router = useRouter(); 98 + const owner = computed(() => String(route.params.owner ?? "")); 99 + const repoName = computed(() => String(route.params.repo ?? "")); 104 100 105 - type Segment = "overview" | "files" | "issues" | "prs"; 101 + type Segment = "overview" | "files" | "issues" | "prs"; 106 102 107 - function segmentFromQuery(value: unknown): Segment { 108 - return value === "files" || value === "issues" || value === "prs" ? value : "overview"; 109 - } 103 + function segmentFromQuery(value: unknown): Segment { 104 + return value === "files" || value === "issues" || value === "prs" ? value : "overview"; 105 + } 110 106 111 - const segment = ref<Segment>("overview"); 107 + const segment = ref<Segment>("overview"); 112 108 113 - watch( 114 - () => route.query.tab, 115 - (value) => { 116 - segment.value = segmentFromQuery(value); 117 - }, 118 - { immediate: true }, 119 - ); 109 + watch( 110 + () => route.query.tab, 111 + (value) => { 112 + segment.value = segmentFromQuery(value); 113 + }, 114 + { immediate: true }, 115 + ); 120 116 121 - watch(segment, (value) => { 122 - const nextQuery = { ...route.query }; 123 - if (value === "overview") { 124 - delete nextQuery.tab; 125 - } else { 126 - nextQuery.tab = value; 127 - } 128 - router.replace({ path: route.path, query: nextQuery }); 129 - }); 117 + watch(segment, (value) => { 118 + const nextQuery = { ...route.query }; 119 + if (value === "overview") { 120 + delete nextQuery.tab; 121 + } else { 122 + nextQuery.tab = value; 123 + } 124 + router.replace({ path: route.path, query: nextQuery }); 125 + }); 130 126 131 - const recordQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 132 - const hasRecord = computed(() => !!recordQuery.data.value); 127 + const recordQuery = useRepoRecord(owner, repoName, { enabled: computed(() => !!owner.value && !!repoName.value) }); 128 + const hasRecord = computed(() => !!recordQuery.data.value); 133 129 134 - const branchQuery = useDefaultBranch(owner, repoName, { enabled: hasRecord }); 135 - const defaultBranch = computed(() => branchQuery.data.value?.name ?? ""); 136 - const hasBranch = computed(() => !!branchQuery.data.value?.name); 137 - const markdownContext = computed<RepoAssetContext | undefined>(() => { 138 - if (!owner.value || !repoName.value || !defaultBranch.value) return undefined; 130 + const branchQuery = useDefaultBranch(owner, repoName, { enabled: hasRecord }); 131 + const defaultBranch = computed(() => branchQuery.data.value?.name ?? ""); 132 + const hasBranch = computed(() => !!branchQuery.data.value?.name); 133 + const markdownContext = computed<RepoAssetContext | undefined>(() => { 134 + if (!owner.value || !repoName.value || !defaultBranch.value) return undefined; 139 135 140 - return { 141 - owner: owner.value, 142 - repo: repoName.value, 143 - branch: defaultBranch.value, 144 - sourcePath: "README.md", 145 - }; 146 - }); 136 + return { owner: owner.value, repo: repoName.value, branch: defaultBranch.value, sourcePath: "README.md" }; 137 + }); 147 138 148 - const languagesQuery = useRepoLanguages(owner, repoName, undefined, { enabled: hasBranch }); 149 - const readmeQuery = useRepoBlob(owner, repoName, defaultBranch, "README.md", { readme: true, enabled: hasBranch }); 150 - const logQuery = useRepoLog(owner, repoName, defaultBranch, { limit: 20, enabled: hasBranch }); 139 + const languagesQuery = useRepoLanguages(owner, repoName, undefined, { enabled: hasBranch }); 140 + const readmeQuery = useRepoBlob(owner, repoName, defaultBranch, "README.md", { readme: true, enabled: hasBranch }); 141 + const logQuery = useRepoLog(owner, repoName, defaultBranch, { limit: 20, enabled: hasBranch }); 151 142 152 - const repo = computed((): RepoDetail | undefined => { 153 - const rec = recordQuery.data.value; 154 - if (!rec) return undefined; 155 - return { 156 - ...rec, 157 - stars: starCountQuery.data.value ?? rec.stars, 158 - defaultBranch: defaultBranch.value || undefined, 159 - languages: languagesQuery.data.value, 160 - readme: readmeQuery.data.value?.isBinary ? undefined : readmeQuery.data.value?.content, 161 - }; 162 - }); 143 + const repo = computed((): RepoDetail | undefined => { 144 + const rec = recordQuery.data.value; 145 + if (!rec) return undefined; 146 + return { 147 + ...rec, 148 + stars: starCountQuery.data.value ?? rec.stars, 149 + defaultBranch: defaultBranch.value || undefined, 150 + languages: languagesQuery.data.value, 151 + readme: readmeQuery.data.value?.isBinary ? undefined : readmeQuery.data.value?.content, 152 + }; 153 + }); 163 154 164 - const repoAtUri = computed(() => recordQuery.data.value?.atUri ?? ""); 165 - const hasAtUri = computed(() => !!repoAtUri.value); 155 + const repoAtUri = computed(() => recordQuery.data.value?.atUri ?? ""); 156 + const hasAtUri = computed(() => !!repoAtUri.value); 166 157 167 - const starCountQuery = useRepoStarCount(repoAtUri, { enabled: hasAtUri }); 158 + const starCountQuery = useRepoStarCount(repoAtUri, { enabled: hasAtUri }); 168 159 169 - const issuesQuery = useRepoIssues(owner, repoName, { enabled: hasAtUri }); 170 - const prsQuery = useRepoPRs(owner, repoName, { enabled: hasAtUri }); 160 + const issuesQuery = useRepoIssues(owner, repoName, { enabled: hasAtUri }); 161 + const prsQuery = useRepoPRs(owner, repoName, { enabled: hasAtUri }); 171 162 172 - const commits = computed(() => logQuery.data.value ?? []); 173 - const issues = computed(() => issuesQuery.data.value ?? []); 174 - const prs = computed(() => prsQuery.data.value ?? []); 163 + const commits = computed(() => logQuery.data.value ?? []); 164 + const issues = computed(() => issuesQuery.data.value ?? []); 165 + const prs = computed(() => prsQuery.data.value ?? []); 175 166 176 - const tabPrefix = computed(() => { 177 - if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 178 - if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 179 - return "/tabs/home"; 180 - }); 167 + const tabPrefix = computed(() => { 168 + if (route.path.startsWith("/tabs/explore")) return "/tabs/explore"; 169 + if (route.path.startsWith("/tabs/activity")) return "/tabs/activity"; 170 + return "/tabs/home"; 171 + }); 181 172 182 - const isLoading = computed(() => recordQuery.isPending.value); 183 - const isError = computed(() => recordQuery.isError.value); 184 - const errorMessage = computed(() => { 185 - const err = recordQuery.error.value; 186 - return err instanceof Error ? err.message : "An unexpected error occurred."; 187 - }); 173 + const isLoading = computed(() => recordQuery.isPending.value); 174 + const isError = computed(() => recordQuery.isError.value); 175 + const errorMessage = computed(() => { 176 + const err = recordQuery.error.value; 177 + return err instanceof Error ? err.message : "An unexpected error occurred."; 178 + }); 188 179 189 - function openIssue(issue: { rkey: string }) { 190 - router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/issues/${issue.rkey}?tab=issues`); 191 - } 180 + function openIssue(issue: { rkey: string }) { 181 + router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/issues/${issue.rkey}?tab=issues`); 182 + } 192 183 193 - function openPullRequest(pr: { rkey: string }) { 194 - router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/pulls/${pr.rkey}?tab=prs`); 195 - } 184 + function openPullRequest(pr: { rkey: string }) { 185 + router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/pulls/${pr.rkey}?tab=prs`); 186 + } 196 187 </script> 197 188 198 189 <style scoped> 199 - .repo-title { 200 - font-family: var(--t-mono); 201 - font-size: 14px; 202 - } 190 + .repo-title { 191 + font-family: var(--t-mono); 192 + font-size: 14px; 193 + } 203 194 204 - .owner { 205 - color: var(--t-text-muted); 206 - font-weight: 400; 207 - } 195 + .owner { 196 + color: var(--t-text-muted); 197 + font-weight: 400; 198 + } 208 199 209 - .detail-segment { 210 - padding: 0 12px 6px; 211 - } 200 + .detail-segment { 201 + padding: 0 12px 6px; 202 + } 212 203 </style>
+185 -188
apps/twisted/src/features/repo/RepoFiles.vue
··· 36 36 {{ formatSize(blobQuery.data.value.size) }} 37 37 </span> 38 38 </div> 39 - <div 40 - v-if="highlightedHtml" 41 - class="file-content shiki-wrap" 42 - v-html="highlightedHtml" /> 39 + <div v-if="highlightedHtml" class="file-content shiki-wrap" v-html="highlightedHtml" /> 43 40 <pre v-else class="file-content"><code>{{ blobQuery.data.value.content }}</code></pre> 44 41 </div> 45 42 </template> ··· 67 64 </template> 68 65 69 66 <script setup lang="ts"> 70 - import { ref, computed, watch, onBeforeUnmount } from "vue"; 71 - import { IonList, IonButton, IonIcon } from "@ionic/vue"; 72 - import { folderOpenOutline, alertCircleOutline, arrowBackOutline, documentOutline } from "ionicons/icons"; 73 - import FileTreeItem from "@/components/repo/FileTreeItem.vue"; 74 - import EmptyState from "@/components/common/EmptyState.vue"; 75 - import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 76 - import { useRepoBlob, useRepoTree } from "@/services/tangled/queries.js"; 77 - import type { RepoFile } from "@/domain/models/repo.js"; 78 - import { highlightCode } from "@/lib/syntax.js"; 79 - import { createObjectUrlFromBlobContent } from "@/services/tangled/repo-assets.js"; 67 + import { ref, computed, watch, onBeforeUnmount } from "vue"; 68 + import { IonList, IonButton, IonIcon } from "@ionic/vue"; 69 + import { folderOpenOutline, alertCircleOutline, arrowBackOutline, documentOutline } from "ionicons/icons"; 70 + import FileTreeItem from "@/components/repo/FileTreeItem.vue"; 71 + import EmptyState from "@/components/common/EmptyState.vue"; 72 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 73 + import { useRepoBlob, useRepoTree } from "@/services/tangled/queries.js"; 74 + import type { RepoFile } from "@/domain/models/repo.js"; 75 + import { highlightCode } from "@/lib/syntax.js"; 76 + import { createObjectUrlFromBlobContent } from "@/services/tangled/repo-assets.js"; 80 77 81 - const props = defineProps<{ owner: string; repo: string; branch: string }>(); 78 + const props = defineProps<{ owner: string; repo: string; branch: string }>(); 82 79 83 - const selectedFile = ref<RepoFile | null>(null); 84 - const currentPath = ref(""); 80 + const selectedFile = ref<RepoFile | null>(null); 81 + const currentPath = ref(""); 85 82 86 - const treeQuery = useRepoTree( 87 - computed(() => props.owner), 88 - computed(() => props.repo), 89 - computed(() => props.branch), 90 - currentPath, 91 - { enabled: computed(() => !!props.owner && !!props.repo && !!props.branch) }, 92 - ); 83 + const treeQuery = useRepoTree( 84 + computed(() => props.owner), 85 + computed(() => props.repo), 86 + computed(() => props.branch), 87 + currentPath, 88 + { enabled: computed(() => !!props.owner && !!props.repo && !!props.branch) }, 89 + ); 93 90 94 - const sortedFiles = computed(() => { 95 - const files = treeQuery.data.value ?? []; 96 - return [...files].sort((a, b) => { 97 - if (a.type === b.type) return a.name.localeCompare(b.name); 98 - if (a.type === "dir") return -1; 99 - if (b.type === "dir") return 1; 100 - if (a.type === "submodule") return -1; 101 - if (b.type === "submodule") return 1; 102 - return 0; 91 + const sortedFiles = computed(() => { 92 + const files = treeQuery.data.value ?? []; 93 + return [...files].sort((a, b) => { 94 + if (a.type === b.type) return a.name.localeCompare(b.name); 95 + if (a.type === "dir") return -1; 96 + if (b.type === "dir") return 1; 97 + if (a.type === "submodule") return -1; 98 + if (b.type === "submodule") return 1; 99 + return 0; 100 + }); 103 101 }); 104 - }); 102 + 103 + const filePath = computed(() => selectedFile.value?.path ?? ""); 104 + const isFileSelected = computed(() => !!selectedFile.value && selectedFile.value.type === "file"); 105 + 106 + const blobQuery = useRepoBlob( 107 + computed(() => props.owner), 108 + computed(() => props.repo), 109 + computed(() => props.branch), 110 + filePath, 111 + { enabled: isFileSelected }, 112 + ); 105 113 106 - const filePath = computed(() => selectedFile.value?.path ?? ""); 107 - const isFileSelected = computed(() => !!selectedFile.value && selectedFile.value.type === "file"); 114 + function handleFileClick(file: RepoFile) { 115 + if (file.type === "dir") { 116 + currentPath.value = file.path; 117 + selectedFile.value = null; 118 + return; 119 + } 108 120 109 - const blobQuery = useRepoBlob( 110 - computed(() => props.owner), 111 - computed(() => props.repo), 112 - computed(() => props.branch), 113 - filePath, 114 - { enabled: isFileSelected }, 115 - ); 121 + if (file.type === "submodule") return; 116 122 117 - function handleFileClick(file: RepoFile) { 118 - if (file.type === "dir") { 119 - currentPath.value = file.path; 120 - selectedFile.value = null; 121 - return; 123 + selectedFile.value = file; 122 124 } 123 125 124 - if (file.type === "submodule") return; 125 - 126 - selectedFile.value = file; 127 - } 126 + function goBack() { 127 + if (selectedFile.value) { 128 + selectedFile.value = null; 129 + return; 130 + } 128 131 129 - function goBack() { 130 - if (selectedFile.value) { 131 - selectedFile.value = null; 132 - return; 132 + if (!currentPath.value) return; 133 + const segments = currentPath.value.split("/").filter(Boolean); 134 + segments.pop(); 135 + currentPath.value = segments.join("/"); 133 136 } 134 137 135 - if (!currentPath.value) return; 136 - const segments = currentPath.value.split("/").filter(Boolean); 137 - segments.pop(); 138 - currentPath.value = segments.join("/"); 139 - } 138 + const highlightedHtml = ref<string | null>(null); 139 + const binaryPreviewUrl = ref<string | null>(null); 140 + 141 + watch( 142 + () => [blobQuery.data.value?.content, selectedFile.value?.name] as const, 143 + async ([content, name]) => { 144 + highlightedHtml.value = null; 145 + if (!content || !name) return; 146 + highlightedHtml.value = await highlightCode(content, name); 147 + }, 148 + { immediate: true }, 149 + ); 140 150 141 - const highlightedHtml = ref<string | null>(null); 142 - const binaryPreviewUrl = ref<string | null>(null); 151 + watch( 152 + () => blobQuery.data.value, 153 + (blob) => { 154 + if (binaryPreviewUrl.value) { 155 + URL.revokeObjectURL(binaryPreviewUrl.value); 156 + binaryPreviewUrl.value = null; 157 + } 143 158 144 - watch( 145 - () => [blobQuery.data.value?.content, selectedFile.value?.name] as const, 146 - async ([content, name]) => { 147 - highlightedHtml.value = null; 148 - if (!content || !name) return; 149 - highlightedHtml.value = await highlightCode(content, name); 150 - }, 151 - { immediate: true }, 152 - ); 159 + if (!blob) return; 160 + binaryPreviewUrl.value = createObjectUrlFromBlobContent(blob); 161 + }, 162 + { immediate: true }, 163 + ); 153 164 154 - watch( 155 - () => blobQuery.data.value, 156 - (blob) => { 165 + onBeforeUnmount(() => { 157 166 if (binaryPreviewUrl.value) { 158 167 URL.revokeObjectURL(binaryPreviewUrl.value); 159 - binaryPreviewUrl.value = null; 160 168 } 161 - 162 - if (!blob) return; 163 - binaryPreviewUrl.value = createObjectUrlFromBlobContent(blob); 164 - }, 165 - { immediate: true }, 166 - ); 169 + }); 167 170 168 - onBeforeUnmount(() => { 169 - if (binaryPreviewUrl.value) { 170 - URL.revokeObjectURL(binaryPreviewUrl.value); 171 + function formatSize(bytes: number): string { 172 + if (bytes < 1024) return `${bytes} B`; 173 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 174 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 171 175 } 172 - }); 173 - 174 - function formatSize(bytes: number): string { 175 - if (bytes < 1024) return `${bytes} B`; 176 - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 177 - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 178 - } 179 176 </script> 180 177 181 178 <style scoped> 182 - .files-view { 183 - padding-bottom: 32px; 184 - } 179 + .files-view { 180 + padding-bottom: 32px; 181 + } 185 182 186 - .file-list { 187 - background: transparent; 188 - padding: 8px 0; 189 - } 183 + .file-list { 184 + background: transparent; 185 + padding: 8px 0; 186 + } 190 187 191 - .viewer-header { 192 - display: flex; 193 - align-items: center; 194 - gap: 8px; 195 - padding: 8px 8px 4px; 196 - border-bottom: 1px solid var(--t-border); 197 - } 188 + .viewer-header { 189 + display: flex; 190 + align-items: center; 191 + gap: 8px; 192 + padding: 8px 8px 4px; 193 + border-bottom: 1px solid var(--t-border); 194 + } 198 195 199 - .back-btn { 200 - --color: var(--t-accent); 201 - flex-shrink: 0; 202 - } 196 + .back-btn { 197 + --color: var(--t-accent); 198 + flex-shrink: 0; 199 + } 203 200 204 - .file-path { 205 - font-family: var(--t-mono); 206 - font-size: 12px; 207 - color: var(--t-text-secondary); 208 - overflow: hidden; 209 - text-overflow: ellipsis; 210 - white-space: nowrap; 211 - } 201 + .file-path { 202 + font-family: var(--t-mono); 203 + font-size: 12px; 204 + color: var(--t-text-secondary); 205 + overflow: hidden; 206 + text-overflow: ellipsis; 207 + white-space: nowrap; 208 + } 212 209 213 - .file-content-wrap { 214 - overflow: auto; 215 - } 210 + .file-content-wrap { 211 + overflow: auto; 212 + } 216 213 217 - .image-preview-wrap { 218 - display: flex; 219 - flex-direction: column; 220 - } 214 + .image-preview-wrap { 215 + display: flex; 216 + flex-direction: column; 217 + } 221 218 222 - .image-preview { 223 - display: block; 224 - width: 100%; 225 - height: auto; 226 - object-fit: contain; 227 - background: var(--t-surface-raised); 228 - } 229 - 230 - .file-meta { 231 - padding: 6px 16px; 232 - border-bottom: 1px solid var(--t-border); 233 - display: flex; 234 - justify-content: flex-end; 235 - } 219 + .image-preview { 220 + display: block; 221 + width: 100%; 222 + height: auto; 223 + object-fit: contain; 224 + background: var(--t-surface-raised); 225 + } 236 226 237 - .file-size { 238 - font-size: 11px; 239 - color: var(--t-text-muted); 240 - font-family: var(--t-mono); 241 - } 227 + .file-meta { 228 + padding: 6px 16px; 229 + border-bottom: 1px solid var(--t-border); 230 + display: flex; 231 + justify-content: flex-end; 232 + } 242 233 243 - .file-content { 244 - margin: 0; 245 - padding: 16px; 246 - font-family: var(--t-mono); 247 - font-size: 12px; 248 - line-height: 1.6; 249 - color: var(--t-text-secondary); 250 - white-space: pre; 251 - overflow-x: auto; 252 - tab-size: 2; 253 - } 234 + .file-size { 235 + font-size: 11px; 236 + color: var(--t-text-muted); 237 + font-family: var(--t-mono); 238 + } 254 239 255 - .binary-notice { 256 - display: flex; 257 - align-items: center; 258 - gap: 8px; 259 - padding: 24px 16px; 260 - font-size: 14px; 261 - color: var(--t-text-muted); 262 - } 240 + .file-content { 241 + margin: 0; 242 + padding: 16px; 243 + font-family: var(--t-mono); 244 + font-size: 12px; 245 + line-height: 1.6; 246 + color: var(--t-text-secondary); 247 + white-space: pre; 248 + overflow-x: auto; 249 + tab-size: 2; 250 + } 263 251 264 - .binary-icon { 265 - font-size: 20px; 266 - } 252 + .binary-notice { 253 + display: flex; 254 + align-items: center; 255 + gap: 8px; 256 + padding: 24px 16px; 257 + font-size: 14px; 258 + color: var(--t-text-muted); 259 + } 267 260 268 - .shiki-wrap :deep(.shiki) { 269 - margin: 0; 270 - padding: 16px; 271 - font-family: var(--t-mono); 272 - font-size: 12px; 273 - line-height: 1.6; 274 - tab-size: 2; 275 - overflow-x: auto; 276 - background: transparent !important; 277 - } 261 + .binary-icon { 262 + font-size: 20px; 263 + } 278 264 279 - .shiki-wrap :deep(.shiki code) { 280 - font-family: inherit; 281 - font-size: inherit; 282 - background: transparent !important; 283 - } 265 + .shiki-wrap :deep(.shiki) { 266 + margin: 0; 267 + padding: 16px; 268 + font-family: var(--t-mono); 269 + font-size: 12px; 270 + line-height: 1.6; 271 + tab-size: 2; 272 + overflow-x: auto; 273 + background: transparent !important; 274 + } 284 275 285 - /* Dual-theme: light tokens visible by default, dark tokens on dark scheme */ 286 - .shiki-wrap :deep(.shiki span) { 287 - color: var(--shiki-light); 288 - } 276 + .shiki-wrap :deep(.shiki code) { 277 + font-family: inherit; 278 + font-size: inherit; 279 + background: transparent !important; 280 + } 289 281 290 - @media (prefers-color-scheme: dark) { 282 + /* Dual-theme: light tokens visible by default, dark tokens on dark scheme */ 291 283 .shiki-wrap :deep(.shiki span) { 292 - color: var(--shiki-dark); 284 + color: var(--shiki-light); 285 + } 286 + 287 + @media (prefers-color-scheme: dark) { 288 + .shiki-wrap :deep(.shiki span) { 289 + color: var(--shiki-dark); 290 + } 293 291 } 294 - } 295 292 </style>
+122 -122
apps/twisted/src/features/repo/RepoIssues.vue
··· 55 55 </template> 56 56 57 57 <script setup lang="ts"> 58 - import { ref, computed } from "vue"; 59 - import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip, IonSpinner } from "@ionic/vue"; 60 - import { chatbubbleOutline, alertCircleOutline } from "ionicons/icons"; 61 - import EmptyState from "@/components/common/EmptyState.vue"; 62 - import type { IssueSummary } from "@/domain/models/issue.js"; 58 + import { ref, computed } from "vue"; 59 + import { IonList, IonItem, IonLabel, IonBadge, IonIcon, IonChip, IonSpinner } from "@ionic/vue"; 60 + import { chatbubbleOutline, alertCircleOutline } from "ionicons/icons"; 61 + import EmptyState from "@/components/common/EmptyState.vue"; 62 + import type { IssueSummary } from "@/domain/models/issue.js"; 63 63 64 - const props = defineProps<{ issues: IssueSummary[]; isLoading?: boolean }>(); 65 - const emit = defineEmits<{ select: [issue: IssueSummary] }>(); 64 + const props = defineProps<{ issues: IssueSummary[]; isLoading?: boolean }>(); 65 + const emit = defineEmits<{ select: [issue: IssueSummary] }>(); 66 66 67 - const filter = ref<"all" | "open" | "closed">("open"); 67 + const filter = ref<"all" | "open" | "closed">("open"); 68 68 69 - const FILTERS = [ 70 - { value: "open", label: "Open" }, 71 - { value: "closed", label: "Closed" }, 72 - { value: "all", label: "All" }, 73 - ] as const; 69 + const FILTERS = [ 70 + { value: "open", label: "Open" }, 71 + { value: "closed", label: "Closed" }, 72 + { value: "all", label: "All" }, 73 + ] as const; 74 74 75 - const filtered = computed(() => { 76 - if (filter.value === "all") return props.issues; 77 - return props.issues.filter((i) => i.state === filter.value); 78 - }); 75 + const filtered = computed(() => { 76 + if (filter.value === "all") return props.issues; 77 + return props.issues.filter((i) => i.state === filter.value); 78 + }); 79 79 80 - function relativeTime(iso: string): string { 81 - const diff = Date.now() - new Date(iso).getTime(); 82 - const m = Math.floor(diff / 60000); 83 - const h = Math.floor(m / 60); 84 - const d = Math.floor(h / 24); 85 - if (d > 0) return `${d}d ago`; 86 - if (h > 0) return `${h}h ago`; 87 - if (m > 0) return `${m}m ago`; 88 - return "just now"; 89 - } 80 + function relativeTime(iso: string): string { 81 + const diff = Date.now() - new Date(iso).getTime(); 82 + const m = Math.floor(diff / 60000); 83 + const h = Math.floor(m / 60); 84 + const d = Math.floor(h / 24); 85 + if (d > 0) return `${d}d ago`; 86 + if (h > 0) return `${h}h ago`; 87 + if (m > 0) return `${m}m ago`; 88 + return "just now"; 89 + } 90 90 </script> 91 91 92 92 <style scoped> 93 - .issues-view { 94 - padding-bottom: 32px; 95 - } 93 + .issues-view { 94 + padding-bottom: 32px; 95 + } 96 96 97 - .loading-center { 98 - display: flex; 99 - justify-content: center; 100 - padding: 48px 0; 101 - } 97 + .loading-center { 98 + display: flex; 99 + justify-content: center; 100 + padding: 48px 0; 101 + } 102 102 103 - .filters-row { 104 - display: flex; 105 - gap: 6px; 106 - padding: 12px 16px 8px; 107 - } 103 + .filters-row { 104 + display: flex; 105 + gap: 6px; 106 + padding: 12px 16px 8px; 107 + } 108 108 109 - .filter-chip { 110 - --background: var(--t-surface-raised); 111 - --color: var(--t-text-secondary); 112 - border: 1px solid var(--t-border); 113 - font-size: 13px; 114 - margin: 0; 115 - cursor: pointer; 116 - } 109 + .filter-chip { 110 + --background: var(--t-surface-raised); 111 + --color: var(--t-text-secondary); 112 + border: 1px solid var(--t-border); 113 + font-size: 13px; 114 + margin: 0; 115 + cursor: pointer; 116 + } 117 117 118 - .filter-chip.active { 119 - --background: var(--t-accent-dim); 120 - --color: var(--t-accent); 121 - border-color: var(--t-accent); 122 - } 118 + .filter-chip.active { 119 + --background: var(--t-accent-dim); 120 + --color: var(--t-accent); 121 + border-color: var(--t-accent); 122 + } 123 123 124 - .issue-list { 125 - background: transparent; 126 - padding: 0; 127 - } 124 + .issue-list { 125 + background: transparent; 126 + padding: 0; 127 + } 128 128 129 - .issue-item { 130 - --background: transparent; 131 - --padding-start: 16px; 132 - --inner-padding-end: 12px; 133 - } 129 + .issue-item { 130 + --background: transparent; 131 + --padding-start: 16px; 132 + --inner-padding-end: 12px; 133 + } 134 134 135 - .state-dot { 136 - width: 8px; 137 - height: 8px; 138 - border-radius: 50%; 139 - flex-shrink: 0; 140 - margin-right: 12px; 141 - } 135 + .state-dot { 136 + width: 8px; 137 + height: 8px; 138 + border-radius: 50%; 139 + flex-shrink: 0; 140 + margin-right: 12px; 141 + } 142 142 143 - .state-dot.open { 144 - background: var(--t-green); 145 - } 146 - .state-dot.closed { 147 - background: var(--t-text-muted); 148 - } 143 + .state-dot.open { 144 + background: var(--t-green); 145 + } 146 + .state-dot.closed { 147 + background: var(--t-text-muted); 148 + } 149 149 150 - .issue-label { 151 - white-space: normal; 152 - padding: 10px 0; 153 - } 150 + .issue-label { 151 + white-space: normal; 152 + padding: 10px 0; 153 + } 154 154 155 - .issue-title { 156 - font-size: 13px; 157 - font-weight: 500; 158 - color: var(--t-text-primary); 159 - display: block; 160 - margin-bottom: 4px; 161 - line-height: 1.4; 162 - } 155 + .issue-title { 156 + font-size: 13px; 157 + font-weight: 500; 158 + color: var(--t-text-primary); 159 + display: block; 160 + margin-bottom: 4px; 161 + line-height: 1.4; 162 + } 163 163 164 - .issue-meta { 165 - display: flex; 166 - align-items: center; 167 - gap: 4px; 168 - font-size: 12px; 169 - color: var(--t-text-muted); 170 - flex-wrap: wrap; 171 - } 164 + .issue-meta { 165 + display: flex; 166 + align-items: center; 167 + gap: 4px; 168 + font-size: 12px; 169 + color: var(--t-text-muted); 170 + flex-wrap: wrap; 171 + } 172 172 173 - .mono { 174 - font-family: var(--t-mono); 175 - font-size: 11px; 176 - color: var(--t-accent); 177 - } 173 + .mono { 174 + font-family: var(--t-mono); 175 + font-size: 11px; 176 + color: var(--t-accent); 177 + } 178 178 179 - .sep { 180 - color: var(--t-border-strong); 181 - } 179 + .sep { 180 + color: var(--t-border-strong); 181 + } 182 182 183 - .meta-icon { 184 - font-size: 11px; 185 - } 183 + .meta-icon { 184 + font-size: 11px; 185 + } 186 186 187 - .state-badge { 188 - font-size: 11px; 189 - font-weight: 500; 190 - border-radius: 99px; 191 - padding: 2px 8px; 192 - text-transform: capitalize; 193 - } 187 + .state-badge { 188 + font-size: 11px; 189 + font-weight: 500; 190 + border-radius: 99px; 191 + padding: 2px 8px; 192 + text-transform: capitalize; 193 + } 194 194 195 - .state-badge.open { 196 - --background: var(--t-green-dim); 197 - --color: var(--t-green); 198 - } 199 - .state-badge.closed { 200 - --background: transparent; 201 - --color: var(--t-text-muted); 202 - border: 1px solid var(--t-border-strong); 203 - } 195 + .state-badge.open { 196 + --background: var(--t-green-dim); 197 + --color: var(--t-green); 198 + } 199 + .state-badge.closed { 200 + --background: transparent; 201 + --color: var(--t-text-muted); 202 + border: 1px solid var(--t-border-strong); 203 + } 204 204 </style>
+1 -4
apps/twisted/src/lib/html.ts
··· 1 1 import DOMPurify from "dompurify"; 2 2 3 - const SANITIZE_CONFIG = { 4 - USE_PROFILES: { html: true }, 5 - ADD_ATTR: ["class", "style"], 6 - }; 3 + const SANITIZE_CONFIG = { USE_PROFILES: { html: true }, ADD_ATTR: ["class", "style"] }; 7 4 8 5 export function sanitizeRichHtml(html: string): string { 9 6 return stripHtmlComments(DOMPurify.sanitize(html, SANITIZE_CONFIG));
+7 -15
apps/twisted/src/services/project-api/queries.ts
··· 68 68 function stripHighlight(html?: string): string | undefined { 69 69 if (!html) return undefined; 70 70 71 - const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); 71 + const text = html 72 + .replace(/<[^>]+>/g, " ") 73 + .replace(/\s+/g, " ") 74 + .trim(); 72 75 return text || undefined; 73 76 } 74 77 ··· 157 160 const repos = response.results.filter((result) => result.record_type === "repo").map(toRepoSummary); 158 161 const profiles = response.results.filter((result) => result.record_type === "profile").map(toUserSummary); 159 162 160 - return { 161 - query: response.query, 162 - mode: response.mode, 163 - total: response.total, 164 - repos, 165 - profiles, 166 - }; 163 + return { query: response.query, mode: response.mode, total: response.total, repos, profiles }; 167 164 }, 168 165 enabled, 169 166 staleTime: 2 * MIN, ··· 171 168 }); 172 169 } 173 170 174 - export function useIndexedProfileSummary( 175 - did: MaybeRef<string>, 176 - options: { enabled?: MaybeRef<boolean> } = {}, 177 - ) { 171 + export function useIndexedProfileSummary(did: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 178 172 const normalizedDid = computed(() => toValue(did).trim()); 179 173 const enabled = computed( 180 174 () => 181 - hasTwisterApi && 182 - normalizedDid.value.length > 0 && 183 - (options.enabled === undefined || !!toValue(options.enabled)), 175 + hasTwisterApi && normalizedDid.value.length > 0 && (options.enabled === undefined || !!toValue(options.enabled)), 184 176 ); 185 177 186 178 return useQuery({
+22 -22
apps/twisted/src/views/HomePage.vue
··· 27 27 </template> 28 28 29 29 <script setup lang="ts"> 30 - import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from "@ionic/vue"; 30 + import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from "@ionic/vue"; 31 31 </script> 32 32 33 33 <style scoped> 34 - #container { 35 - text-align: center; 34 + #container { 35 + text-align: center; 36 36 37 - position: absolute; 38 - left: 0; 39 - right: 0; 40 - top: 50%; 41 - transform: translateY(-50%); 42 - } 37 + position: absolute; 38 + left: 0; 39 + right: 0; 40 + top: 50%; 41 + transform: translateY(-50%); 42 + } 43 43 44 - #container strong { 45 - font-size: 20px; 46 - line-height: 26px; 47 - } 44 + #container strong { 45 + font-size: 20px; 46 + line-height: 26px; 47 + } 48 48 49 - #container p { 50 - font-size: 16px; 51 - line-height: 22px; 49 + #container p { 50 + font-size: 16px; 51 + line-height: 22px; 52 52 53 - color: #8c8c8c; 53 + color: #8c8c8c; 54 54 55 - margin: 0; 56 - } 55 + margin: 0; 56 + } 57 57 58 - #container a { 59 - text-decoration: none; 60 - } 58 + #container a { 59 + text-decoration: none; 60 + } 61 61 </style>
+1 -5
apps/twisted/src/vite-env.d.ts
··· 16 16 use(plugin: (...args: any[]) => unknown, ...params: any[]): MarkdownIt; 17 17 }; 18 18 19 - type MarkdownItOptions = { 20 - html?: boolean; 21 - linkify?: boolean; 22 - typographer?: boolean; 23 - }; 19 + type MarkdownItOptions = { html?: boolean; linkify?: boolean; typographer?: boolean }; 24 20 25 21 const markdownit: (options?: MarkdownItOptions) => MarkdownIt; 26 22 export default markdownit;