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