tangled
alpha
login
or
join now
vt3e.cat
/
bbell
7
fork
atom
wip bsky client for the web & android
bbell.vt3e.cat
7
fork
atom
overview
issues
pulls
pipelines
feat: uhh following/followers pages; new auth page; i forgot
vt3e.cat
1 month ago
ab334541
8c9868d0
verified
This commit was signed with the committer's
known signature
.
vt3e.cat
SSH Key Fingerprint:
SHA256:MaVgF6bXxDdD131G4rXizPh+sttp3IVsdPrj48HV0X0=
+1543
-389
26 changed files
expand all
collapse all
unified
split
.zed
settings.json
android
app
capacitor.build.gradle
capacitor.settings.gradle
bun.lock
package.json
src
assets
main.css
components
Feed
FeedItem.vue
Navigation
BackButton.vue
NavItem.vue
PageLayout.vue
TabStack.vue
Profile
ProfileRow.vue
UI
BaseButton.vue
ListItem.vue
composables
useFollowToggle.ts
useInfiniteScroll.ts
usePagedProfiles.ts
router
index.ts
stores
auth.ts
utils
haptics.ts
views
Auth
LoginPage.vue
Profile
FollowersView.vue
FollowingView.vue
FollowsView.vue
ProfileView.vue
SettingsPage.vue
+1
-1
.zed/settings.json
···
1
{
2
-
"hard_tabs": false
3
}
···
1
{
2
+
"hard_tabs": true,
3
}
+1
-1
android/app/capacitor.build.gradle
···
10
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
11
dependencies {
12
implementation project(':capacitor-app')
13
-
14
}
15
16
···
10
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
11
dependencies {
12
implementation project(':capacitor-app')
13
+
implementation project(':capacitor-haptics')
14
}
15
16
+3
android/capacitor.settings.gradle
···
4
5
include ':capacitor-app'
6
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
0
0
0
···
4
5
include ':capacitor-app'
6
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
7
+
8
+
include ':capacitor-haptics'
9
+
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
+3
bun.lock
···
13
"@capacitor/android": "^8.0.0",
14
"@capacitor/app": "^8.0.0",
15
"@capacitor/core": "^8.0.0",
0
16
"@iconify-prerendered/vue-material-symbols": "^0.28.1755063979",
17
"add": "^2.0.6",
18
"android": "^0.0.8",
···
139
"@capacitor/cli": ["@capacitor/cli@8.0.0", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.1", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^6.1.11", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-v9hEBi69xGxuuZhg55N031bMEenKaPSv71Il8C22VOOH6surDyv/MPeImN0oVfFc7eiklaW3rDFYVz6cmXfJWQ=="],
140
141
"@capacitor/core": ["@capacitor/core@8.0.0", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-250HTVd/W/KdMygoqaedisvNbHbpbQTN2Hy/8ZYGm1nAqE0Fx7sGss4l0nDg33STxEdDhtVRoL2fIaaiukKseA=="],
0
0
142
143
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
144
···
13
"@capacitor/android": "^8.0.0",
14
"@capacitor/app": "^8.0.0",
15
"@capacitor/core": "^8.0.0",
16
+
"@capacitor/haptics": "^8.0.0",
17
"@iconify-prerendered/vue-material-symbols": "^0.28.1755063979",
18
"add": "^2.0.6",
19
"android": "^0.0.8",
···
140
"@capacitor/cli": ["@capacitor/cli@8.0.0", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.1", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^6.1.11", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-v9hEBi69xGxuuZhg55N031bMEenKaPSv71Il8C22VOOH6surDyv/MPeImN0oVfFc7eiklaW3rDFYVz6cmXfJWQ=="],
141
142
"@capacitor/core": ["@capacitor/core@8.0.0", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-250HTVd/W/KdMygoqaedisvNbHbpbQTN2Hy/8ZYGm1nAqE0Fx7sGss4l0nDg33STxEdDhtVRoL2fIaaiukKseA=="],
143
+
144
+
"@capacitor/haptics": ["@capacitor/haptics@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA=="],
145
146
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
147
+1
package.json
···
27
"@capacitor/android": "^8.0.0",
28
"@capacitor/app": "^8.0.0",
29
"@capacitor/core": "^8.0.0",
0
30
"@iconify-prerendered/vue-material-symbols": "^0.28.1755063979",
31
"add": "^2.0.6",
32
"android": "^0.0.8",
···
27
"@capacitor/android": "^8.0.0",
28
"@capacitor/app": "^8.0.0",
29
"@capacitor/core": "^8.0.0",
30
+
"@capacitor/haptics": "^8.0.0",
31
"@iconify-prerendered/vue-material-symbols": "^0.28.1755063979",
32
"add": "^2.0.6",
33
"android": "^0.0.8",
+6
src/assets/main.css
···
180
.mb-4 {
181
margin-bottom: var(--space-4);
182
}
0
0
0
0
0
0
···
180
.mb-4 {
181
margin-bottom: var(--space-4);
182
}
183
+
184
+
.dot {
185
+
user-select: none;
186
+
color: hsl(var(--surface2));
187
+
font-weight: 900;
188
+
}
+16
-8
src/components/Feed/FeedItem.vue
···
17
import EmbedRecord from './Embeds/EmbedRecord.vue'
18
import ExternalEmbed from './Embeds/ExternalEmbed.vue'
19
import VideoEmbed from './Embeds/VideoEmbed.vue'
0
20
21
type PostInput = AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord
22
···
78
}
79
80
const handleLike = () => {
81
-
if (displayPost.value && !props.embedded) postStore.toggleLike(displayPost.value)
0
0
0
82
}
83
84
const handleRepost = () => {
85
-
if (displayPost.value && !props.embedded) postStore.toggleRepost(displayPost.value)
0
0
0
86
}
87
88
const handleClick = (e: MouseEvent) => {
···
145
<div v-if="item?.reason?.$type === 'app.bsky.feed.defs#reasonRepost'" class="repost-indicator">
146
<IconRefreshRounded class="repost-icon" />
147
<span>Reposted by {{ item.reason.by.displayName || item.reason.by.handle }}</span>
0
148
</div>
149
150
<div class="post-layout">
···
164
displayPost.author.displayName || displayPost.author.handle
165
}}</span>
166
<span class="handle">@{{ displayPost.author.handle }}</span>
0
0
0
0
167
<span class="dot" aria-hidden="true">·</span>
168
<span class="time">{{ formatTime(displayPost.indexedAt) }}</span>
169
</div>
···
363
font-weight: 400;
364
}
365
366
-
.dot {
367
-
user-select: none;
368
-
color: hsl(var(--surface2));
369
-
}
370
-
371
-
.time {
372
color: hsl(var(--subtext0));
373
font-size: 0.85rem;
374
flex-shrink: 0;
···
17
import EmbedRecord from './Embeds/EmbedRecord.vue'
18
import ExternalEmbed from './Embeds/ExternalEmbed.vue'
19
import VideoEmbed from './Embeds/VideoEmbed.vue'
20
+
import { tap } from '@/utils/haptics'
21
22
type PostInput = AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord
23
···
79
}
80
81
const handleLike = () => {
82
+
if (displayPost.value && !props.embedded) {
83
+
postStore.toggleLike(displayPost.value)
84
+
tap()
85
+
}
86
}
87
88
const handleRepost = () => {
89
+
if (displayPost.value && !props.embedded) {
90
+
postStore.toggleRepost(displayPost.value)
91
+
tap()
92
+
}
93
}
94
95
const handleClick = (e: MouseEvent) => {
···
152
<div v-if="item?.reason?.$type === 'app.bsky.feed.defs#reasonRepost'" class="repost-indicator">
153
<IconRefreshRounded class="repost-icon" />
154
<span>Reposted by {{ item.reason.by.displayName || item.reason.by.handle }}</span>
155
+
<span class="repost-indicator__time"> · {{ formatTime(item.reason.indexedAt) }} </span>
156
</div>
157
158
<div class="post-layout">
···
172
displayPost.author.displayName || displayPost.author.handle
173
}}</span>
174
<span class="handle">@{{ displayPost.author.handle }}</span>
175
+
<template v-if="displayPost.author.pronouns">
176
+
<span class="dot" aria-hidden="true">·</span>
177
+
<span class="pronouns">{{ displayPost.author.pronouns }}</span>
178
+
</template>
179
<span class="dot" aria-hidden="true">·</span>
180
<span class="time">{{ formatTime(displayPost.indexedAt) }}</span>
181
</div>
···
375
font-weight: 400;
376
}
377
378
+
.time,
379
+
.pronouns {
0
0
0
0
380
color: hsl(var(--subtext0));
381
font-size: 0.85rem;
382
flex-shrink: 0;
+2
-2
src/components/Navigation/BackButton.vue
···
77
padding: 0.5rem;
78
79
&:hover {
80
-
background: hsla(var(--surface1) / 1);
81
}
82
&:active {
83
-
background: hsla(var(--surface2) / 1);
84
}
85
86
.icon {
···
77
padding: 0.5rem;
78
79
&:hover {
80
+
background: hsla(var(--surface0) / 1);
81
}
82
&:active {
83
+
background: hsla(var(--surface0) / 0.5);
84
}
85
86
.icon {
+2
src/components/Navigation/NavItem.vue
···
1
<script lang="ts" setup>
2
import { useNavigationStore } from '@/stores/navigation'
3
import type { Page, PageNames } from '@/router'
0
4
5
const nav = useNavigationStore()
6
defineProps<{ item: Page }>()
···
9
const currentTab = nav.activeTab
10
if (currentTab === tab) nav.resetTab(tab)
11
else nav.switchTab(tab as PageNames)
0
12
}
13
14
const handleKeydown = (event: KeyboardEvent, tab: string) => {
···
1
<script lang="ts" setup>
2
import { useNavigationStore } from '@/stores/navigation'
3
import type { Page, PageNames } from '@/router'
4
+
import { tap } from '@/utils/haptics'
5
6
const nav = useNavigationStore()
7
defineProps<{ item: Page }>()
···
10
const currentTab = nav.activeTab
11
if (currentTab === tab) nav.resetTab(tab)
12
else nav.switchTab(tab as PageNames)
13
+
tap()
14
}
15
16
const handleKeydown = (event: KeyboardEvent, tab: string) => {
+1
-1
src/components/Navigation/PageLayout.vue
···
104
-webkit-overflow-scrolling: touch;
105
height: 100%;
106
overflow-y: scroll;
107
-
padding-top: calc(var(--inset-top, 0) + 3.5rem);
108
109
display: flex;
110
flex-direction: column;
···
104
-webkit-overflow-scrolling: touch;
105
height: 100%;
106
overflow-y: scroll;
107
+
padding-top: calc(var(--inset-top, 0) + 4.5rem);
108
109
display: flex;
110
flex-direction: column;
+57
-68
src/components/Navigation/TabStack.vue
···
1
<script lang="ts" setup>
2
-
import { computed, ref, watch, defineAsyncComponent } from "vue";
3
-
import { useNavigationStore } from "@/stores/navigation";
4
-
import { pages, type StackRootNames } from "@/router";
5
-
import { useEnvironmentStore } from "@/stores/environment";
6
7
-
const props = defineProps<{ tab: StackRootNames }>();
8
-
const nav = useNavigationStore();
9
-
const env = useEnvironmentStore();
10
11
-
const stack = computed(() => nav.stacks[props.tab]);
12
0
13
const registry: Record<string, any> = pages.reduce(
14
(acc, page) => {
15
-
const comp = page.component;
16
acc[page.name] =
17
-
typeof comp === "function"
18
? defineAsyncComponent({
19
loader: comp as unknown as () => Promise<any>,
20
})
21
-
: comp;
22
-
return acc;
23
},
24
{} as Record<string, any>,
25
-
);
26
27
-
const isAnimating = ref(false);
28
-
const animationType = ref<"push" | "pop" | null>(null);
29
-
const previousStackLength = ref(stack.value?.length || 0);
30
31
const visualTopIndex = computed(() => {
32
-
if (nav.pendingPop?.tab === props.tab)
33
-
return stack.value?.length ? stack.value.length - 2 : 0;
34
-
return stack.value?.length ? stack.value.length - 1 : 0;
35
-
});
36
37
const shouldAnimate = computed(() => {
38
-
return env.isMobile && !env.prefersReducedMotion;
39
-
});
40
41
watch(
42
() => stack.value?.length,
43
(newLength, oldLength) => {
44
-
if (!newLength || !oldLength) return;
45
46
if (nav.activeTab !== props.tab) {
47
-
previousStackLength.value = newLength;
48
-
return;
49
}
50
51
if (newLength > oldLength && shouldAnimate.value) {
52
-
animationType.value = "push";
53
-
isAnimating.value = true;
54
55
setTimeout(() => {
56
-
isAnimating.value = false;
57
-
animationType.value = null;
58
-
}, 300);
59
}
60
61
-
previousStackLength.value = newLength;
62
},
63
-
);
64
65
watch(
66
() => nav.pendingPop,
67
(pendingPop) => {
68
-
if (
69
-
!pendingPop ||
70
-
pendingPop.tab !== props.tab ||
71
-
nav.activeTab !== props.tab
72
-
) {
73
-
return;
74
}
75
76
if (!shouldAnimate.value) {
77
-
nav.completePop();
78
-
return;
79
}
80
81
-
animationType.value = "pop";
82
-
isAnimating.value = true;
83
84
setTimeout(() => {
85
-
isAnimating.value = false;
86
-
animationType.value = null;
87
-
nav.completePop();
88
-
}, 300);
89
},
90
{ immediate: true },
91
-
);
92
</script>
93
94
<template>
···
105
v-for="(entry, index) in stack"
106
:key="entry.id"
107
:class="[
108
-
'stack-page',
109
-
{
110
-
'is-visible':
111
-
index === stack.length - 1 ||
112
-
index === visualTopIndex ||
113
-
index === visualTopIndex - 1,
114
115
-
'is-visual-top': index === visualTopIndex,
116
-
'is-below-visual-top': index === visualTopIndex - 1,
117
-
'is-animating': isAnimating,
118
-
'push-enter':
119
-
isAnimating &&
120
-
animationType === 'push' &&
121
-
index === stack.length - 1,
122
-
'pop-exit':
123
-
isAnimating &&
124
-
animationType === 'pop' &&
125
-
index === stack.length - 1,
126
-
},
127
-
128
-
]"
129
:data-entry-id="entry.id"
130
:data-index="index"
131
:aria-hidden="index !== visualTopIndex"
···
133
>
134
<Suspense>
135
<template #default>
136
-
<component :is="registry[entry.page]" v-bind="entry.props"/>
137
</template>
138
<template #fallback>
139
<div class="page-loading" aria-hidden="true"></div>
···
1
<script lang="ts" setup>
2
+
import { computed, ref, watch, defineAsyncComponent } from 'vue'
3
+
import { useNavigationStore } from '@/stores/navigation'
4
+
import { pages, type StackRootNames } from '@/router'
5
+
import { useEnvironmentStore } from '@/stores/environment'
6
7
+
const props = defineProps<{ tab: StackRootNames }>()
8
+
const nav = useNavigationStore()
9
+
const env = useEnvironmentStore()
10
11
+
const stack = computed(() => nav.stacks[props.tab])
12
13
+
// TODO)) rm the `any`s
14
const registry: Record<string, any> = pages.reduce(
15
(acc, page) => {
16
+
const comp = page.component
17
acc[page.name] =
18
+
typeof comp === 'function'
19
? defineAsyncComponent({
20
loader: comp as unknown as () => Promise<any>,
21
})
22
+
: comp
23
+
return acc
24
},
25
{} as Record<string, any>,
26
+
)
27
28
+
const isAnimating = ref(false)
29
+
const animationType = ref<'push' | 'pop' | null>(null)
30
+
const previousStackLength = ref(stack.value?.length || 0)
31
32
const visualTopIndex = computed(() => {
33
+
if (nav.pendingPop?.tab === props.tab) return stack.value?.length ? stack.value.length - 2 : 0
34
+
return stack.value?.length ? stack.value.length - 1 : 0
35
+
})
0
36
37
const shouldAnimate = computed(() => {
38
+
return env.isMobile && !env.prefersReducedMotion
39
+
})
40
41
watch(
42
() => stack.value?.length,
43
(newLength, oldLength) => {
44
+
if (!newLength || !oldLength) return
45
46
if (nav.activeTab !== props.tab) {
47
+
previousStackLength.value = newLength
48
+
return
49
}
50
51
if (newLength > oldLength && shouldAnimate.value) {
52
+
animationType.value = 'push'
53
+
isAnimating.value = true
54
55
setTimeout(() => {
56
+
isAnimating.value = false
57
+
animationType.value = null
58
+
}, 300)
59
}
60
61
+
previousStackLength.value = newLength
62
},
63
+
)
64
65
watch(
66
() => nav.pendingPop,
67
(pendingPop) => {
68
+
if (!pendingPop || pendingPop.tab !== props.tab || nav.activeTab !== props.tab) {
69
+
return
0
0
0
0
70
}
71
72
if (!shouldAnimate.value) {
73
+
nav.completePop()
74
+
return
75
}
76
77
+
animationType.value = 'pop'
78
+
isAnimating.value = true
79
80
setTimeout(() => {
81
+
isAnimating.value = false
82
+
animationType.value = null
83
+
nav.completePop()
84
+
}, 300)
85
},
86
{ immediate: true },
87
+
)
88
</script>
89
90
<template>
···
101
v-for="(entry, index) in stack"
102
:key="entry.id"
103
:class="[
104
+
'stack-page',
105
+
{
106
+
'is-visible':
107
+
index === stack.length - 1 ||
108
+
index === visualTopIndex ||
109
+
index === visualTopIndex - 1,
110
111
+
'is-visual-top': index === visualTopIndex,
112
+
'is-below-visual-top': index === visualTopIndex - 1,
113
+
'is-animating': isAnimating,
114
+
'push-enter': isAnimating && animationType === 'push' && index === stack.length - 1,
115
+
'pop-exit': isAnimating && animationType === 'pop' && index === stack.length - 1,
116
+
},
117
+
]"
0
0
0
0
0
0
0
118
:data-entry-id="entry.id"
119
:data-index="index"
120
:aria-hidden="index !== visualTopIndex"
···
122
>
123
<Suspense>
124
<template #default>
125
+
<component :is="registry[entry.page]" v-bind="entry.props" :routeName="entry.page" />
126
</template>
127
<template #fallback>
128
<div class="page-loading" aria-hidden="true"></div>
+205
src/components/Profile/ProfileRow.vue
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script setup lang="ts">
2
+
import { computed } from 'vue'
3
+
import type { AppBskyActorDefs } from '@atcute/bluesky'
4
+
import { useFollowToggle } from '@/composables/useFollowToggle'
5
+
6
+
const props = defineProps<{ profile: AppBskyActorDefs.ProfileView }>()
7
+
const emit = defineEmits(['toggled'])
8
+
const { toggle } = useFollowToggle()
9
+
10
+
import AppLink from '../Navigation/AppLink.vue'
11
+
12
+
const isFollowing = computed(() => !!props.profile.viewer?.following)
13
+
const isMutual = computed(
14
+
() => !!(props.profile.viewer?.following && props.profile.viewer?.followedBy),
15
+
)
16
+
const label = computed(() => {
17
+
if (isMutual.value) return 'Mutuals'
18
+
return isFollowing.value ? 'Following' : 'Follow'
19
+
})
20
+
21
+
async function onClick(e: Event) {
22
+
e.stopPropagation()
23
+
try {
24
+
await toggle(props.profile)
25
+
emit('toggled', props.profile)
26
+
} catch (err) {
27
+
console.error(err)
28
+
}
29
+
}
30
+
</script>
31
+
32
+
<template>
33
+
<AppLink
34
+
:key="profile.did"
35
+
:class="{
36
+
follow: true,
37
+
blocked: profile.viewer?.blockedBy,
38
+
blocking: profile.viewer?.blocking,
39
+
}"
40
+
name="user-profile"
41
+
:params="{ id: profile.did }"
42
+
role="listitem"
43
+
>
44
+
<img
45
+
class="avatar"
46
+
:src="profile.avatar"
47
+
:alt="`${profile.displayName || profile.handle}'s avatar`"
48
+
loading="lazy"
49
+
/>
50
+
<div class="info">
51
+
<div class="top">
52
+
<div class="display-name">{{ profile.displayName || profile.handle }}</div>
53
+
<span class="handle">@{{ profile.handle }}</span>
54
+
<span v-if="profile.pronouns" class="dot" aria-hidden="true">·</span>
55
+
<span v-if="profile.pronouns" class="pronouns">{{ profile.pronouns }}</span>
56
+
</div>
57
+
<div class="meta">
58
+
<span v-if="profile.description" class="bio">{{ profile.description }}</span>
59
+
</div>
60
+
</div>
61
+
62
+
<button
63
+
class="follow-btn"
64
+
:class="{ mutual: isMutual, following: isFollowing }"
65
+
@click.stop.prevent="onClick"
66
+
:aria-pressed="!!profile.viewer?.following"
67
+
:title="
68
+
isMutual ? 'Mutuals - click to unfollow' : profile.viewer?.following ? 'Unfollow' : 'Follow'
69
+
"
70
+
>
71
+
{{ label }}
72
+
</button>
73
+
</AppLink>
74
+
</template>
75
+
76
+
<style lang="scss" scoped>
77
+
.follow {
78
+
display: flex;
79
+
align-items: center;
80
+
gap: 0.75rem;
81
+
padding: 0.75rem;
82
+
/* border-radius: 0.75rem; */
83
+
/* background: hsla(var(--base) / 0.6); */
84
+
border-bottom: 1px solid hsla(var(--surface2) / 0.35);
85
+
text-decoration: none;
86
+
color: inherit;
87
+
88
+
&:hover {
89
+
background: hsla(var(--surface0) / 0.2);
90
+
}
91
+
92
+
&.blocked {
93
+
filter: brightness(0.75);
94
+
}
95
+
&.blocking {
96
+
filter: brightness(0.75);
97
+
}
98
+
99
+
.avatar {
100
+
width: 3rem;
101
+
height: 3rem;
102
+
border-radius: 50%;
103
+
object-fit: cover;
104
+
flex: 0 0 auto;
105
+
}
106
+
107
+
.info {
108
+
flex: 1;
109
+
display: flex;
110
+
flex-direction: column;
111
+
overflow: hidden;
112
+
113
+
.top {
114
+
display: flex;
115
+
align-items: center;
116
+
gap: 0.5rem;
117
+
min-width: 0;
118
+
font-size: 0.9rem;
119
+
120
+
.display-name {
121
+
font-weight: 700;
122
+
white-space: nowrap;
123
+
text-overflow: ellipsis;
124
+
overflow: hidden;
125
+
}
126
+
127
+
.handle {
128
+
color: hsl(var(--subtext1));
129
+
font-size: 0.9rem;
130
+
}
131
+
132
+
.pronouns {
133
+
color: hsl(var(--subtext0));
134
+
white-space: nowrap;
135
+
}
136
+
137
+
.badge {
138
+
margin-left: auto;
139
+
font-size: 0.72rem;
140
+
padding: 0.15rem 0.4rem;
141
+
border-radius: 999px;
142
+
background: hsla(var(--surface2) / 0.45);
143
+
color: hsl(var(--subtext1));
144
+
font-weight: 600;
145
+
}
146
+
147
+
.badge.mutual {
148
+
background: hsla(var(--accent) / 0.12);
149
+
color: hsl(var(--accent));
150
+
}
151
+
}
152
+
153
+
.meta {
154
+
color: hsl(var(--subtext0));
155
+
font-size: 0.88rem;
156
+
margin-top: 0.25rem;
157
+
overflow: hidden;
158
+
text-overflow: ellipsis;
159
+
white-space: nowrap;
160
+
}
161
+
}
162
+
163
+
.follow-btn {
164
+
flex: 0 0 auto;
165
+
padding: 0.35rem 0.75rem;
166
+
border-radius: 999px;
167
+
border: none;
168
+
cursor: pointer;
169
+
color: hsl(var(--crust));
170
+
font-weight: 700;
171
+
172
+
background: hsla(var(--accent) / 0.85);
173
+
174
+
&:hover {
175
+
background: hsla(var(--accent) / 1);
176
+
}
177
+
&:active {
178
+
background: hsla(var(--accent) / 0.75);
179
+
}
180
+
181
+
&.following {
182
+
background: transparent;
183
+
border: 1px solid hsla(var(--surface2) / 0.75);
184
+
color: hsl(var(--text));
185
+
&:hover {
186
+
background: hsla(var(--surface0) / 0.9);
187
+
}
188
+
&:active {
189
+
background: hsla(var(--surface0) / 0.5);
190
+
}
191
+
}
192
+
/* &.mutual {
193
+
background: hsla(var(--accent) / 0.85);
194
+
color: hsl(var(--base));
195
+
border: 1px solid hsla(var(--surface2) / 0.45);
196
+
&:hover {
197
+
background: hsla(var(--accent) / 1);
198
+
}
199
+
&:active {
200
+
background: hsla(var(--accent) / 0.6);
201
+
}
202
+
} */
203
+
}
204
+
}
205
+
</style>
+23
-8
src/components/UI/BaseButton.vue
···
1
<script setup lang="ts">
2
withDefaults(
3
defineProps<{
4
-
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt'
5
size?: 'sm' | 'md' | 'lg'
6
icon?: boolean
7
block?: boolean
···
74
color: hsl(var(--text-colour));
75
border-color: hsla(var(--border-colour) / 0.2);
76
77
-
&:hover:not(:disabled) {
78
-
background-color: hsla(var(--bg-colour) / 1);
79
-
border-color: hsla(var(--border-colour) / 0.5);
80
-
}
0
81
82
-
&:active:not(:disabled) {
83
-
background-color: hsla(var(--bg-colour) / 0.7);
84
-
border-color: hsla(var(--border-colour) / 0.5);
0
85
}
86
}
87
···
191
&:active:not(:disabled) {
192
background-color: hsla(var(--red) / 0.3);
193
border-color: hsla(var(--red) / 0.3);
0
0
0
0
0
0
0
0
0
0
0
0
0
194
}
195
}
196
···
1
<script setup lang="ts">
2
withDefaults(
3
defineProps<{
4
+
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt' | 'text'
5
size?: 'sm' | 'md' | 'lg'
6
icon?: boolean
7
block?: boolean
···
74
color: hsl(var(--text-colour));
75
border-color: hsla(var(--border-colour) / 0.2);
76
77
+
&:not(.variant-text) {
78
+
&:hover:not(:disabled) {
79
+
background-color: hsla(var(--bg-colour) / 1);
80
+
border-color: hsla(var(--border-colour) / 0.5);
81
+
}
82
83
+
&:active:not(:disabled) {
84
+
background-color: hsla(var(--bg-colour) / 0.7);
85
+
border-color: hsla(var(--border-colour) / 0.5);
86
+
}
87
}
88
}
89
···
193
&:active:not(:disabled) {
194
background-color: hsla(var(--red) / 0.3);
195
border-color: hsla(var(--red) / 0.3);
196
+
}
197
+
}
198
+
199
+
.variant-text {
200
+
background-color: none;
201
+
color: hsl(var(--text-colour));
202
+
border-color: transparent;
203
+
204
+
&:hover {
205
+
color: hsla(var(--text) / 0.9);
206
+
}
207
+
&:active {
208
+
color: hsla(var(--text) / 0.75);
209
}
210
}
211
+114
-114
src/components/UI/ListItem.vue
···
4
import { IconChevronRightRounded } from '@iconify-prerendered/vue-material-symbols'
5
6
const props = defineProps<{
7
-
title?: string
8
-
subtitle?: string
9
-
chevron?: boolean
10
-
clickable?: boolean
11
-
danger?: boolean
12
13
-
href?: string
14
-
to?: string | object
15
-
target?: string
0
16
}>()
17
18
const emit = defineEmits<{
19
-
(e: 'click', event: MouseEvent): void
20
}>()
21
22
const isLink = computed(() => !!props.href || !!props.to)
23
const isInteractive = computed(() => props.clickable || isLink.value)
24
25
const componentType = computed(() => {
26
-
if (props.to) return AppLink
27
-
if (props.href) return 'a'
28
-
return 'div'
29
})
30
31
function handleClick(e: MouseEvent) {
32
-
if (isInteractive.value) emit('click', e)
33
}
34
35
function handleKeydown(e: KeyboardEvent) {
36
-
if (!isInteractive.value) return
37
-
if (!isLink.value && (e.key === 'Enter' || e.key === ' ')) {
38
-
e.preventDefault()
39
-
emit('click', e as unknown as MouseEvent)
40
-
}
41
}
42
</script>
43
44
<template>
45
-
<component
46
-
:is="componentType"
47
-
class="list-item"
48
-
:class="{ 'is-clickable': isInteractive, 'is-danger': danger }"
49
-
:href="href"
50
-
:to="to"
51
-
:target="target"
52
-
:tabindex="isInteractive ? 0 : -1"
53
-
:role="!isLink && isInteractive ? 'button' : undefined"
54
-
@click="handleClick"
55
-
@keydown="handleKeydown"
56
-
>
57
-
<div v-if="$slots.start" class="item-start">
58
-
<slot name="start" />
59
-
</div>
60
61
-
<div class="item-content">
62
-
<div v-if="title" class="item-title">{{ title }}</div>
63
-
<div v-if="subtitle" class="item-subtitle">{{ subtitle }}</div>
64
-
<slot />
65
-
</div>
66
67
-
<div v-if="$slots.end || chevron" class="item-end">
68
-
<slot name="end" />
69
-
<IconChevronRightRounded v-if="chevron" class="chevron" />
70
-
</div>
71
-
</component>
72
</template>
73
74
<style scoped lang="scss">
75
.list-item {
76
-
position: relative;
77
-
display: flex;
78
-
align-items: center;
79
-
gap: var(--space-3);
80
-
padding: var(--space-4);
81
-
background: hsl(var(--surface0));
82
-
transition: background-color 0.2s ease;
83
-
text-decoration: none;
84
-
color: inherit;
85
-
min-height: 3.5rem;
86
-
outline: none;
87
88
-
&::after {
89
-
content: '';
90
-
position: absolute;
91
-
bottom: 0;
92
-
right: 0;
93
-
left: 1rem;
94
-
height: 1px;
95
-
background-color: hsla(var(--surface2) / 0.5);
96
-
}
97
98
-
&:last-child::after {
99
-
display: none;
100
-
}
101
}
102
103
.is-clickable {
104
-
cursor: pointer;
105
-
&:hover {
106
-
background: hsl(var(--surface1));
107
-
}
108
-
&:active {
109
-
background: hsl(var(--surface2));
110
-
}
111
-
&:focus-visible {
112
-
z-index: 1;
113
-
background: hsl(var(--surface1));
114
-
box-shadow: inset 0 0 0 2px hsl(var(--accent));
115
-
}
116
}
117
118
.is-danger {
119
-
.item-title,
120
-
.item-start,
121
-
.chevron {
122
-
color: hsl(var(--red));
123
-
}
124
}
125
126
.item-start {
127
-
display: flex;
128
-
align-items: center;
129
-
justify-content: center;
130
-
font-size: 1.5rem;
131
-
color: hsl(var(--accent));
132
133
-
svg {
134
-
display: block;
135
-
}
136
}
137
138
.item-content {
139
-
flex: 1;
140
-
display: flex;
141
-
flex-direction: column;
142
-
justify-content: center;
143
-
min-width: 0;
144
-
gap: 2px;
145
}
146
147
.item-title {
148
-
font-weight: 600;
149
-
color: hsl(var(--text));
150
-
font-size: 1rem;
151
-
white-space: nowrap;
152
-
overflow: hidden;
153
-
text-overflow: ellipsis;
154
}
155
156
.item-subtitle {
157
-
font-size: 0.8rem;
158
-
color: hsl(var(--subtext0));
159
-
line-height: 1.3;
160
}
161
162
.item-end {
163
-
display: flex;
164
-
align-items: center;
165
-
gap: var(--space-2);
166
-
color: hsl(var(--subtext1));
167
-
font-size: 0.9rem;
168
-
font-weight: 500;
169
}
170
171
.chevron {
172
-
font-size: 1.25rem;
173
-
opacity: 0.4;
174
}
175
</style>
···
4
import { IconChevronRightRounded } from '@iconify-prerendered/vue-material-symbols'
5
6
const props = defineProps<{
7
+
title?: string
8
+
subtitle?: string
9
+
chevron?: boolean
10
+
clickable?: boolean
11
+
danger?: boolean
12
13
+
href?: string
14
+
to?: string | object
15
+
target?: string
16
+
disabled?: boolean
17
}>()
18
19
const emit = defineEmits<{
20
+
(e: 'click', event: MouseEvent): void
21
}>()
22
23
const isLink = computed(() => !!props.href || !!props.to)
24
const isInteractive = computed(() => props.clickable || isLink.value)
25
26
const componentType = computed(() => {
27
+
if (props.to) return AppLink
28
+
if (props.href) return 'a'
29
+
return 'div'
30
})
31
32
function handleClick(e: MouseEvent) {
33
+
if (isInteractive.value) emit('click', e)
34
}
35
36
function handleKeydown(e: KeyboardEvent) {
37
+
if (!isInteractive.value) return
38
+
if (!isLink.value && (e.key === 'Enter' || e.key === ' ')) {
39
+
e.preventDefault()
40
+
emit('click', e as unknown as MouseEvent)
41
+
}
42
}
43
</script>
44
45
<template>
46
+
<component
47
+
:is="componentType"
48
+
class="list-item"
49
+
:class="{ 'is-clickable': isInteractive, 'is-danger': danger }"
50
+
:href="href"
51
+
:to="to"
52
+
:target="target"
53
+
:tabindex="isInteractive ? 0 : -1"
54
+
:role="!isLink && isInteractive ? 'button' : undefined"
55
+
@click="handleClick"
56
+
@keydown="handleKeydown"
57
+
>
58
+
<div v-if="$slots.start" class="item-start">
59
+
<slot name="start" />
60
+
</div>
61
62
+
<div class="item-content">
63
+
<div v-if="title" class="item-title">{{ title }}</div>
64
+
<div v-if="subtitle" class="item-subtitle">{{ subtitle }}</div>
65
+
<slot />
66
+
</div>
67
68
+
<div v-if="$slots.end || chevron" class="item-end">
69
+
<slot name="end" />
70
+
<IconChevronRightRounded v-if="chevron" class="chevron" />
71
+
</div>
72
+
</component>
73
</template>
74
75
<style scoped lang="scss">
76
.list-item {
77
+
position: relative;
78
+
display: flex;
79
+
align-items: center;
80
+
gap: var(--space-3);
81
+
padding: var(--space-4);
82
+
background: hsl(var(--surface0));
83
+
text-decoration: none;
84
+
color: inherit;
85
+
min-height: 3.5rem;
86
+
outline: none;
0
87
88
+
&::after {
89
+
content: '';
90
+
position: absolute;
91
+
bottom: 0;
92
+
right: 0;
93
+
left: 1rem;
94
+
height: 1px;
95
+
background-color: hsla(var(--surface2) / 0.5);
96
+
}
97
98
+
&:last-child::after {
99
+
display: none;
100
+
}
101
}
102
103
.is-clickable {
104
+
cursor: pointer;
105
+
&:hover {
106
+
background: hsla(var(--surface1) / 1);
107
+
}
108
+
&:active {
109
+
background: hsla(var(--surface1) / 0.6);
110
+
}
111
+
&:focus-visible {
112
+
z-index: 1;
113
+
background: hsl(var(--surface1));
114
+
box-shadow: inset 0 0 0 2px hsl(var(--accent));
115
+
}
116
}
117
118
.is-danger {
119
+
.item-title,
120
+
.item-start,
121
+
.chevron {
122
+
color: hsl(var(--red));
123
+
}
124
}
125
126
.item-start {
127
+
display: flex;
128
+
align-items: center;
129
+
justify-content: center;
130
+
font-size: 1.5rem;
131
+
color: hsl(var(--accent));
132
133
+
svg {
134
+
display: block;
135
+
}
136
}
137
138
.item-content {
139
+
flex: 1;
140
+
display: flex;
141
+
flex-direction: column;
142
+
justify-content: center;
143
+
min-width: 0;
144
+
gap: 2px;
145
}
146
147
.item-title {
148
+
font-weight: 600;
149
+
color: hsl(var(--text));
150
+
font-size: 1rem;
151
+
white-space: nowrap;
152
+
overflow: hidden;
153
+
text-overflow: ellipsis;
154
}
155
156
.item-subtitle {
157
+
font-size: 0.8rem;
158
+
color: hsl(var(--subtext0));
159
+
line-height: 1.3;
160
}
161
162
.item-end {
163
+
display: flex;
164
+
align-items: center;
165
+
gap: var(--space-2);
166
+
color: hsl(var(--subtext1));
167
+
font-size: 0.9rem;
168
+
font-weight: 500;
169
}
170
171
.chevron {
172
+
font-size: 1.25rem;
173
+
opacity: 0.4;
174
}
175
</style>
+42
src/composables/useFollowToggle.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { useAuthStore } from '@/stores/auth'
2
+
import type { ViewerState } from '@atcute/bluesky/types/app/actor/defs'
3
+
4
+
export function useFollowToggle() {
5
+
const auth = useAuthStore()
6
+
async function toggle(profile: { did: string; viewer?: ViewerState }) {
7
+
if (!auth.isAuthenticated || !auth.session) return
8
+
const rpc = auth.getRpc()
9
+
const original = profile.viewer?.following
10
+
profile.viewer = profile.viewer || {}
11
+
if (original) profile.viewer.following = undefined
12
+
else profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary`
13
+
14
+
try {
15
+
if (original) {
16
+
const rkey = original.split('/').pop()!
17
+
await rpc.post('com.atproto.repo.deleteRecord', {
18
+
input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey },
19
+
})
20
+
} else {
21
+
const { data, ok } = await rpc.post('com.atproto.repo.createRecord', {
22
+
input: {
23
+
collection: 'app.bsky.graph.follow',
24
+
repo: auth.session.info.sub,
25
+
record: {
26
+
$type: 'app.bsky.graph.follow',
27
+
subject: profile.did,
28
+
createdAt: new Date().toISOString(),
29
+
},
30
+
},
31
+
})
32
+
if (ok) profile.viewer.following = data.uri
33
+
}
34
+
} catch (e) {
35
+
// revert
36
+
profile.viewer.following = original
37
+
throw e
38
+
}
39
+
}
40
+
41
+
return { toggle }
42
+
}
+21
src/composables/useInfiniteScroll.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { onBeforeUnmount } from 'vue'
2
+
3
+
export function useInfiniteScroll(
4
+
sentinelRef: { value: HTMLElement | null },
5
+
onIntersect: () => void,
6
+
opts = { rootMargin: '200px', threshold: 0.1 },
7
+
) {
8
+
let obs: IntersectionObserver | null = null
9
+
const setup = () => {
10
+
if (!sentinelRef.value) return
11
+
obs = new IntersectionObserver((entries) => {
12
+
for (const e of entries) if (e.isIntersecting) onIntersect()
13
+
}, opts)
14
+
obs.observe(sentinelRef.value)
15
+
}
16
+
onBeforeUnmount(() => {
17
+
if (obs && sentinelRef.value) obs.unobserve(sentinelRef.value)
18
+
obs = null
19
+
})
20
+
return { setup }
21
+
}
+33
src/composables/usePagedProfiles.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { shallowRef, ref } from 'vue'
2
+
import type { AppBskyActorDefs } from '@atcute/bluesky'
3
+
4
+
export function usePagedProfiles<T extends AppBskyActorDefs.ProfileView>() {
5
+
const items = shallowRef<T[]>([])
6
+
const cursor = ref<string | null>(null)
7
+
const loading = ref(false)
8
+
const error = ref<string | null>(null)
9
+
10
+
async function fetchPage(
11
+
fetcher: (cursor?: string | null) => Promise<{ items: T[]; cursor?: string | null }>,
12
+
reset = false,
13
+
) {
14
+
if (loading.value) return
15
+
loading.value = true
16
+
error.value = null
17
+
if (reset) {
18
+
items.value = []
19
+
cursor.value = null
20
+
}
21
+
try {
22
+
const res = await fetcher(cursor.value || undefined)
23
+
items.value.push(...res.items)
24
+
cursor.value = res.cursor ?? null
25
+
} catch (e) {
26
+
error.value = e instanceof Error ? e.message : String(e)
27
+
} finally {
28
+
loading.value = false
29
+
}
30
+
}
31
+
32
+
return { items, cursor, loading, error, fetchPage }
33
+
}
+23
-4
src/router/index.ts
···
12
path: string
13
component: Component | (() => Promise<Component>)
14
icon?: Component | (() => Promise<Component>)
0
15
}
16
17
-
export const pages = [
0
0
18
{
19
root: true,
20
label: 'Home',
···
43
label: 'User Profile',
44
name: 'user-profile',
45
path: '/profile/:id',
46
-
component: () => import('@/views/UserProfile.vue'),
0
0
0
0
0
0
0
0
0
0
0
0
0
0
47
},
48
{
49
root: false,
···
74
path: '/profile/:identifier/post/:rkey',
75
component: () => import('@/views/Post/PostView.vue'),
76
},
77
-
] as const satisfies readonly Page[]
0
0
78
79
export const stackRoots = pages.filter((page) => page.root)
80
export type PageNames = (typeof pages)[number]['name']
···
131
url = url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
132
if (props[key] !== undefined) {
133
usedKeys.add(key)
134
-
return encodeURIComponent(String(props[key]))
135
}
136
return ':' + key
137
})
···
12
path: string
13
component: Component | (() => Promise<Component>)
14
icon?: Component | (() => Promise<Component>)
15
+
defaultProps?: Record<string, unknown>
16
}
17
18
+
export const devPages: Page[] = []
19
+
20
+
export const mainPages: Page[] = [
21
{
22
root: true,
23
label: 'Home',
···
46
label: 'User Profile',
47
name: 'user-profile',
48
path: '/profile/:id',
49
+
component: () => import('@/views/Profile/ProfileView.vue'),
50
+
},
51
+
{
52
+
root: false,
53
+
label: 'Followers',
54
+
name: 'user-followers',
55
+
path: '/profile/:id/followers',
56
+
component: () => import('@/views/Profile/FollowsView.vue'),
57
+
},
58
+
{
59
+
root: false,
60
+
label: 'Follows',
61
+
name: 'user-follows',
62
+
path: '/profile/:id/follows',
63
+
component: () => import('@/views/Profile/FollowsView.vue'),
64
},
65
{
66
root: false,
···
91
path: '/profile/:identifier/post/:rkey',
92
component: () => import('@/views/Post/PostView.vue'),
93
},
94
+
]
95
+
96
+
export const pages = [...mainPages, ...devPages] as const satisfies readonly Page[]
97
98
export const stackRoots = pages.filter((page) => page.root)
99
export type PageNames = (typeof pages)[number]['name']
···
150
url = url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
151
if (props[key] !== undefined) {
152
usedKeys.add(key)
153
+
return String(props[key])
154
}
155
return ':' + key
156
})
+9
-1
src/stores/auth.ts
···
25
import _KEYS from '@/utils/keys'
26
const KEYS = _KEYS.AUTH
27
0
0
0
28
export const useAuthStore = defineStore('auth', () => {
29
const session = ref<Session | null>(null)
30
const agent = ref<OAuthUserAgent | null>(null)
···
85
const data = await ok(
86
rpc.get('app.bsky.actor.getProfile', {
87
params: { actor: session.value.info.sub },
0
0
0
88
}),
89
)
90
profile.value = data
···
93
}
94
}
95
96
-
async function login(input: string) {
97
isLoading.value = true
98
error.value = null
99
···
105
authUrl = await createAuthorizationUrl({
106
target: { type: 'pds', serviceUrl: input },
107
scope,
0
0
108
})
109
} else {
110
try {
···
25
import _KEYS from '@/utils/keys'
26
const KEYS = _KEYS.AUTH
27
28
+
// TODO)) add as setting
29
+
export const BSKY_APPVIEW = 'did:web:api.bsky.app#bsky_appview'
30
+
31
export const useAuthStore = defineStore('auth', () => {
32
const session = ref<Session | null>(null)
33
const agent = ref<OAuthUserAgent | null>(null)
···
88
const data = await ok(
89
rpc.get('app.bsky.actor.getProfile', {
90
params: { actor: session.value.info.sub },
91
+
headers: {
92
+
'atproto-proxy': BSKY_APPVIEW,
93
+
},
94
}),
95
)
96
profile.value = data
···
99
}
100
}
101
102
+
async function login(input: string, createAccount = false) {
103
isLoading.value = true
104
error.value = null
105
···
111
authUrl = await createAuthorizationUrl({
112
target: { type: 'pds', serviceUrl: input },
113
scope,
114
+
// @ts-expect-error: craete will be available soon:tm:
115
+
prompt: createAccount ? 'create' : undefined,
116
})
117
} else {
118
try {
+67
src/utils/haptics.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
/**
2
+
* this is v experimental.
3
+
* for the time being, since i cba to write a capacitor plugin for the system
4
+
* haptics api, we're just using the browser api.
5
+
*/
6
+
7
+
import {
8
+
Haptics,
9
+
type VibrateOptions,
10
+
type NotificationOptions,
11
+
type ImpactOptions,
12
+
ImpactStyle,
13
+
} from '@capacitor/haptics'
14
+
15
+
export function isVibrationsEnabled(): boolean {
16
+
return true
17
+
}
18
+
19
+
export function tap() {
20
+
Haptics.vibrate({ duration: 1 })
21
+
}
22
+
23
+
export async function impact(options?: ImpactOptions): Promise<void> {
24
+
if (!isVibrationsEnabled()) return
25
+
try {
26
+
await Haptics.impact(options || { style: ImpactStyle.Light })
27
+
} catch {}
28
+
}
29
+
30
+
export async function notification(options?: NotificationOptions): Promise<void> {
31
+
if (!isVibrationsEnabled()) return
32
+
try {
33
+
await Haptics.notification(options)
34
+
} catch {}
35
+
}
36
+
37
+
export async function vibrate(options?: VibrateOptions): Promise<void> {
38
+
if (!isVibrationsEnabled()) return
39
+
try {
40
+
await Haptics.vibrate(options)
41
+
} catch {}
42
+
}
43
+
44
+
async function selectionStart(): Promise<void> {
45
+
if (!isVibrationsEnabled()) return
46
+
try {
47
+
await Haptics.selectionStart()
48
+
} catch {}
49
+
}
50
+
async function selectionChanged(): Promise<void> {
51
+
if (!isVibrationsEnabled()) return
52
+
try {
53
+
await Haptics.selectionChanged()
54
+
} catch {}
55
+
}
56
+
async function selectionEnd(): Promise<void> {
57
+
if (!isVibrationsEnabled()) return
58
+
try {
59
+
await Haptics.selectionEnd()
60
+
} catch {}
61
+
}
62
+
63
+
export const selection = {
64
+
start: selectionStart,
65
+
changed: selectionChanged,
66
+
end: selectionEnd,
67
+
}
+200
-176
src/views/Auth/LoginPage.vue
···
1
<script setup lang="ts">
2
-
import { ref, watch } from 'vue'
3
-
import {
4
-
IconArrowForwardRounded,
5
-
IconOpenInNewRounded,
6
-
IconAlternateEmailRounded,
7
-
IconCloseRounded,
8
-
IconProgressActivity,
9
-
} from '@iconify-prerendered/vue-material-symbols'
10
-
import { simpleFetchHandler, Client, ok } from '@atcute/client'
11
12
import { useAuthStore } from '@/stores/auth'
13
import PageLayout from '@/components/Navigation/PageLayout.vue'
14
import Button from '@/components/UI/BaseButton.vue'
15
import Modal from '@/components/UI/BaseModal.vue'
16
import TextInput from '@/components/UI/TextInput.vue'
17
-
import ListItem from '@/components/UI/ListItem.vue'
18
19
const auth = useAuthStore()
20
21
-
const handler = simpleFetchHandler({ service: 'https://public.api.bsky.app' })
22
-
const client = new Client({ handler })
23
-
24
-
const profilePicture = ref<string | null>(null)
25
-
const handle = ref('')
26
-
const showCreateAccount = ref(false)
27
-
const loading = ref(false)
28
-
const hasError = ref(false)
29
-
30
-
const handleSubmit = async () => {
31
-
if (!handle.value) return
32
-
await auth.login(handle.value)
33
}
34
-
const pdsSubmit = async (pds: string) => {
35
-
await auth.login(pds)
36
-
}
37
-
38
-
let debounceTimer: ReturnType<typeof setTimeout>
39
-
40
-
watch(handle, (newHandle) => {
41
-
clearTimeout(debounceTimer)
42
-
hasError.value = false
43
-
loading.value = true
44
-
45
-
if (!newHandle) {
46
-
profilePicture.value = null
47
-
loading.value = false
48
-
return
49
-
}
50
-
51
-
debounceTimer = setTimeout(async () => {
52
-
try {
53
-
const cleanHandle = newHandle.startsWith('@') ? newHandle.slice(1) : newHandle
54
-
const data = ok(
55
-
await client.get('app.bsky.actor.getProfile', {
56
-
params: { actor: cleanHandle },
57
-
}),
58
-
)
59
-
60
-
profilePicture.value = data.avatar || null
61
-
hasError.value = false
62
-
} catch (error) {
63
-
profilePicture.value = null
64
-
hasError.value = true
65
-
console.error('Error fetching profile:', error)
66
-
} finally {
67
-
loading.value = false
68
-
}
69
-
}, 500)
70
-
})
71
-
72
-
const providerList: Array<{ name: string; subtitle?: string; url: string }> = [
73
-
{ name: 'Bluesky PBC', subtitle: 'bsky.social', url: 'https://bsky.social/' },
74
-
{ name: 'selfhosted.social', url: 'https://selfhosted.social/' },
75
{
76
name: 'Tophhie Social',
77
subtitle: 'pds.tophhie.cloud',
78
url: 'https://pds.tophhie.cloud',
0
79
},
80
-
{ name: 'Blacksky', subtitle: 'A PDS for the black community.', url: 'https://blacksky.app/' },
81
-
{ name: 'Spark', subtitle: 'pds.sprk.so', url: 'https://pds.sprk.so' },
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
82
]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
83
</script>
84
85
<template>
86
-
<PageLayout title="Login">
87
<div class="login-view">
88
-
<div class="header">
89
<h1 class="title">Sign in</h1>
90
-
<p class="subtitle">Enter your AT Protocol handle to continue.</p>
91
-
</div>
92
93
-
<form @submit.prevent="handleSubmit" class="form-stack">
94
-
<TextInput
95
-
v-model="handle"
96
-
placeholder="awesome.cat"
97
-
:error="auth.error || undefined"
98
-
@keydown.enter="handleSubmit"
99
-
autofocus
100
>
101
-
<template #prefix>
102
-
<div v-if="loading" class="text-input-prefix spin">
103
-
<IconProgressActivity />
104
-
</div>
105
-
<div v-else-if="profilePicture" class="input-avatar">
106
-
<img :src="profilePicture" alt="Avatar" />
107
-
</div>
108
-
<div v-else class="text-input-prefix">
109
-
<IconCloseRounded v-if="hasError" style="color: hsl(var(--danger))" />
110
-
<IconAlternateEmailRounded v-else />
111
-
</div>
112
-
</template>
113
-
</TextInput>
114
115
-
<div class="actions">
116
-
<Button type="button" variant="subtle-alt" @click="showCreateAccount = true">
117
-
Create account
118
-
</Button>
119
-
<Button type="submit" variant="primary" :loading="auth.isLoading" :disabled="!handle">
120
-
Next <IconArrowForwardRounded />
121
</Button>
122
</div>
123
-
</form>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
124
</div>
125
126
-
<Modal v-model:open="showCreateAccount" title="Create Account">
127
-
<div class="create-account-content">
128
-
<p class="modal-text">
129
-
To use Bluebell, you need to create an Atmosphere account. Here are some open providers
130
-
where you can register.
131
-
</p>
132
133
-
<div class="provider-list">
134
-
<ListItem
135
-
v-for="provider in providerList"
136
-
:key="provider.name"
137
-
:title="provider.name"
138
-
:subtitle="provider.subtitle"
139
-
@click="pdsSubmit(provider.url)"
140
-
clickable
141
-
:disabled="auth.isLoading"
142
-
>
143
-
<template #end><IconOpenInNewRounded /></template>
144
-
</ListItem>
145
-
</div>
146
147
-
<p class="modal-subtext">
148
-
Make sure to to read the provider's terms of service and privacy policy before creating an
149
-
account.
150
-
</p>
151
</div>
0
152
<template #footer>
153
-
<Button variant="ghost" @click="showCreateAccount = false">Close</Button>
0
0
0
154
</template>
155
</Modal>
156
</PageLayout>
···
158
159
<style scoped lang="scss">
160
.login-view {
161
-
max-width: 400px;
162
width: 100%;
163
display: flex;
164
flex-direction: column;
165
gap: 1rem;
0
166
}
167
168
.header {
···
183
line-height: 1.5;
184
}
185
}
186
-
187
-
.form-stack {
188
display: flex;
189
flex-direction: column;
190
-
gap: 1rem;
0
0
0
0
0
0
0
0
0
0
191
192
-
.actions {
193
-
display: flex;
194
-
justify-content: flex-end;
195
-
gap: 1rem;
196
-
}
197
198
-
.text-input-prefix {
199
-
display: flex;
200
-
align-items: center;
0
0
0
0
201
202
-
&.spin {
203
-
animation: spin 1s linear infinite;
204
-
color: hsl(var(--subtext0));
0
205
206
-
@keyframes spin {
207
-
from {
208
-
transform: rotate(0deg);
209
-
}
210
-
to {
211
-
transform: rotate(360deg);
212
-
}
213
-
}
214
-
}
215
}
216
217
-
.input-avatar {
218
-
display: flex;
219
-
align-items: center;
220
-
justify-content: center;
221
-
padding-left: 0.5rem;
222
-
padding-right: 0.5rem;
223
-
height: 100%;
224
225
-
img {
226
-
width: 2rem;
227
-
height: 2rem;
228
-
229
-
border-radius: 50%;
230
-
object-fit: cover;
231
-
background-color: hsl(var(--surface0));
232
-
border: 1px solid hsl(var(--surface2));
233
-
}
234
}
235
}
236
237
-
.create-account-content {
0
238
display: flex;
239
flex-direction: column;
240
-
gap: 1rem;
241
-
padding-top: 0.5rem;
0
242
243
-
.modal-text {
0
0
0
0
0
0
0
244
color: hsl(var(--text));
245
-
line-height: 1.5;
246
}
247
248
-
.modal-subtext {
249
-
font-size: 0.875rem;
250
-
color: hsl(var(--subtext0));
251
-
line-height: 1.4;
0
0
252
}
0
253
254
-
.provider-list {
255
-
display: flex;
256
-
flex-direction: column;
257
-
border: 1px solid hsla(var(--surface2) / 0.5);
258
-
border-radius: var(--radius-lg);
259
-
overflow: hidden;
260
-
}
0
0
0
0
0
0
0
0
0
261
}
262
</style>
···
1
<script setup lang="ts">
2
+
import { ref, computed } from 'vue'
3
+
import { IconOpenInNewRounded } from '@iconify-prerendered/vue-material-symbols'
0
0
0
0
0
0
0
4
5
import { useAuthStore } from '@/stores/auth'
6
import PageLayout from '@/components/Navigation/PageLayout.vue'
7
import Button from '@/components/UI/BaseButton.vue'
8
import Modal from '@/components/UI/BaseModal.vue'
9
import TextInput from '@/components/UI/TextInput.vue'
0
10
11
const auth = useAuthStore()
12
13
+
type Provider = {
14
+
name: string
15
+
url: string
16
+
subtitle?: string
17
+
location: string
0
0
0
0
0
0
0
18
}
19
+
const providerList: Provider[] = [
20
+
{ name: 'Bluesky PBC', subtitle: 'bsky.social', url: 'https://bsky.social/', location: 'US' },
21
+
{
22
+
name: 'selfhosted.social',
23
+
url: 'https://selfhosted.social/',
24
+
subtitle:
25
+
'A collection of hackers, designers, developers, ATProto enthusiasts, scrobblers, tinkerers, friends, and curious minds. A shared space where everyone is welcome to be themselves.',
26
+
location: 'US',
27
+
},
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
28
{
29
name: 'Tophhie Social',
30
subtitle: 'pds.tophhie.cloud',
31
url: 'https://pds.tophhie.cloud',
32
+
location: 'GB',
33
},
34
+
// {
35
+
// name: 'Zio',
36
+
// subtitle: 'zio.blue',
37
+
// url: 'https://zio.blue',
38
+
// location: 'Finland',
39
+
// },
40
+
{
41
+
name: 'peedee.es',
42
+
url: 'https://peedee.es',
43
+
location: 'Germany',
44
+
},
45
+
{
46
+
name: 'Blacksky',
47
+
subtitle: 'A PDS for the black community.',
48
+
url: 'https://blacksky.app/',
49
+
location: 'US',
50
+
},
51
+
{ name: 'Spark', subtitle: 'pds.sprk.so', url: 'https://pds.sprk.so', location: 'US' },
52
]
53
+
54
+
const isLoading = computed(() => auth.isLoading)
55
+
const showPdsModal = ref(false)
56
+
const pdsInput = ref('')
57
+
const pdsError = ref('')
58
+
59
+
async function pdsSubmit(pds: string, providerList = false) {
60
+
if (!pds || isLoading.value) return
61
+
await auth.login(pds, providerList)
62
+
}
63
+
64
+
function openPdsModal() {
65
+
pdsInput.value = ''
66
+
pdsError.value = ''
67
+
showPdsModal.value = true
68
+
}
69
+
70
+
async function submitPds() {
71
+
const val = pdsInput.value.trim()
72
+
if (!val) {
73
+
pdsError.value = 'Enter a PDS URL or handle (e.g. https://pds.example or awesome.cat)'
74
+
return
75
+
}
76
+
showPdsModal.value = false
77
+
await auth.login(val)
78
+
}
79
</script>
80
81
<template>
82
+
<PageLayout title="Sign in">
83
<div class="login-view">
84
+
<header class="header">
85
<h1 class="title">Sign in</h1>
86
+
<p class="subtitle">Pick a provider to sign in with.</p>
87
+
</header>
88
89
+
<div class="button-row compact">
90
+
<Button
91
+
variant="primary"
92
+
size="lg"
93
+
:block="true"
94
+
:loading="isLoading"
95
+
@click="pdsSubmit('https://bsky.social')"
96
>
97
+
<span class="btn-inner">
98
+
<span>Sign in with Bluesky</span>
99
+
</span>
100
+
</Button>
0
0
0
0
0
0
0
0
0
101
102
+
<div class="secondary-row">
103
+
<Button variant="text" @click="openPdsModal" :disabled="isLoading">
104
+
Sign in with AT Protocol
0
0
0
105
</Button>
106
</div>
107
+
</div>
108
+
109
+
<div class="provider-list">
110
+
<button
111
+
v-for="provider in providerList"
112
+
:key="provider.name"
113
+
class="list-item"
114
+
:disabled="isLoading"
115
+
@click="() => pdsSubmit(provider.url, true)"
116
+
>
117
+
<div class="content-grid">
118
+
<div class="top-row">
119
+
<span class="name">{{ provider.name }}</span>
120
+
<span class="location" v-if="provider.location">{{ provider.location }}</span>
121
+
</div>
122
+
123
+
<p class="subtitle">
124
+
{{ provider.subtitle || provider.url.replace('https://', '') }}
125
+
</p>
126
+
</div>
127
+
128
+
<div class="action">
129
+
<IconOpenInNewRounded />
130
+
</div>
131
+
</button>
132
+
</div>
133
+
134
+
<p v-if="auth.error" class="error-text">{{ auth.error }}</p>
135
</div>
136
137
+
<Modal v-model:open="showPdsModal" title="Sign in with PDS">
138
+
<div class="modal-body">
139
+
<label for="pds-input" class="sr-only">PDS URL or handle</label>
0
0
0
140
141
+
<TextInput
142
+
id="pds-input"
143
+
v-model="pdsInput"
144
+
placeholder="https://pds.example or example.cat"
145
+
:error="pdsError"
146
+
inputmode="url"
147
+
aria-describedby="pds-error"
148
+
@keydown.enter.prevent="submitPds"
149
+
style="margin-top: 1rem"
150
+
/>
0
0
0
151
152
+
<div id="pds-error" role="alert" v-if="pdsError">{{ pdsError }}</div>
0
0
0
153
</div>
154
+
155
<template #footer>
156
+
<Button variant="ghost" type="button" @click="showPdsModal = false">Cancel</Button>
157
+
<Button variant="primary" :loading="auth.isLoading" @click="submitPds" type="button"
158
+
>Sign in</Button
159
+
>
160
</template>
161
</Modal>
162
</PageLayout>
···
164
165
<style scoped lang="scss">
166
.login-view {
0
167
width: 100%;
168
display: flex;
169
flex-direction: column;
170
gap: 1rem;
171
+
margin: 0 auto;
172
}
173
174
.header {
···
189
line-height: 1.5;
190
}
191
}
192
+
.button-row {
0
193
display: flex;
194
flex-direction: column;
195
+
gap: 0.5rem;
196
+
}
197
+
.btn-inner {
198
+
display: inline-flex;
199
+
align-items: center;
200
+
gap: 0.6rem;
201
+
}
202
+
.secondary-row {
203
+
display: flex;
204
+
justify-content: center;
205
+
}
206
207
+
.provider-list {
208
+
border-radius: 1rem;
209
+
border: 1px solid hsla(var(--surface2) / 0.5);
210
+
overflow: hidden;
211
+
}
212
213
+
.list-item {
214
+
width: 100%;
215
+
display: flex;
216
+
align-items: center;
217
+
gap: 1rem;
218
+
padding: 0.75rem 1rem;
219
+
text-align: left;
220
221
+
border-color: transparent;
222
+
border-bottom: 1px solid hsla(var(--surface2) / 0.5);
223
+
background: transparent;
224
+
cursor: pointer;
225
226
+
&:hover {
227
+
background: hsla(var(--surface2) / 0.2);
0
0
0
0
0
0
0
228
}
229
230
+
&:focus-visible {
231
+
outline-color: transparent;
232
+
background-color: hsla(var(--accent) / 0.2);
233
+
}
0
0
0
234
235
+
&:last-child {
236
+
border-bottom: none;
0
0
0
0
0
0
0
237
}
238
}
239
240
+
.content-grid {
241
+
flex: 1;
242
display: flex;
243
flex-direction: column;
244
+
gap: 0.2rem;
245
+
min-width: 0;
246
+
}
247
248
+
.top-row {
249
+
display: flex;
250
+
align-items: center;
251
+
gap: 0.5rem;
252
+
253
+
.name {
254
+
font-weight: 600;
255
+
font-size: 0.95rem;
256
color: hsl(var(--text));
257
+
white-space: nowrap;
258
}
259
260
+
.location {
261
+
font-size: 0.7rem;
262
+
padding: 0.1rem 0.5rem;
263
+
border-radius: 1rem;
264
+
background: hsla(var(--accent) / 0.25);
265
+
color: hsl(var(--text));
266
}
267
+
}
268
269
+
.subtitle {
270
+
font-size: 0.8rem;
271
+
color: hsl(var(--subtext0));
272
+
line-height: 1.4;
273
+
}
274
+
275
+
.action {
276
+
color: hsl(var(--subtext0));
277
+
display: flex;
278
+
align-items: center;
279
+
}
280
+
281
+
.error-text {
282
+
color: hsl(var(--red));
283
+
font-weight: 600;
284
+
margin-top: 0.5rem;
285
}
286
</style>
+222
src/views/Profile/FollowersView.vue
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts" setup>
2
+
import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue'
3
+
import type { ActorIdentifier } from '@atcute/lexicons'
4
+
import { AppBskyActorDefs } from '@atcute/bluesky'
5
+
import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth'
6
+
7
+
import PageLayout from '@/components/Navigation/PageLayout.vue'
8
+
import AppLink from '@/components/Navigation/AppLink.vue'
9
+
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
10
+
import Button from '@/components/UI/BaseButton.vue'
11
+
12
+
const props = defineProps<{ id: ActorIdentifier }>()
13
+
const auth = useAuthStore()
14
+
15
+
const follows = ref<AppBskyActorDefs.ProfileView[]>([])
16
+
const cursor = ref<string | null>(null)
17
+
const isLoading = ref(false)
18
+
const isError = ref<string | null>(null)
19
+
const loadMoreTrigger = ref<HTMLElement | null>(null)
20
+
let obs: IntersectionObserver | null = null
21
+
22
+
const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value)
23
+
24
+
async function fetchFollows(reset = false) {
25
+
if (isLoading.value) return
26
+
isLoading.value = true
27
+
isError.value = null
28
+
29
+
if (reset) {
30
+
follows.value = []
31
+
cursor.value = null
32
+
}
33
+
34
+
try {
35
+
const rpc = auth.getRpc()
36
+
const { data, ok } = await rpc.get('app.bsky.graph.getFollowers', {
37
+
params: {
38
+
actor: props.id,
39
+
limit: 50,
40
+
cursor: cursor.value || undefined,
41
+
},
42
+
headers: { 'atproto-proxy': BSKY_APPVIEW },
43
+
})
44
+
45
+
if (!ok) {
46
+
isError.value = (data && data.error) || 'Failed to fetch follows'
47
+
return
48
+
}
49
+
50
+
follows.value.push(...(data.followers || []))
51
+
cursor.value = data.cursor || null
52
+
} catch (e) {
53
+
console.error('fetchFollows error', e)
54
+
isError.value = e instanceof Error ? e.message : 'Unknown error'
55
+
} finally {
56
+
isLoading.value = false
57
+
}
58
+
}
59
+
60
+
async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) {
61
+
if (!auth.isAuthenticated || !auth.session) return
62
+
const rpc = auth.getRpc()
63
+
const original = profile.viewer?.following
64
+
65
+
profile.viewer = profile.viewer || {}
66
+
if (original) {
67
+
profile.viewer.following = undefined
68
+
} else {
69
+
profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary`
70
+
}
71
+
72
+
try {
73
+
if (original) {
74
+
const rkey = original.split('/').pop()!
75
+
await rpc.post('com.atproto.repo.deleteRecord', {
76
+
input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey },
77
+
})
78
+
} else {
79
+
const { data, ok } = await rpc.post('com.atproto.repo.createRecord', {
80
+
input: {
81
+
collection: 'app.bsky.graph.follow',
82
+
repo: auth.session.info.sub,
83
+
record: {
84
+
$type: 'app.bsky.graph.follow',
85
+
subject: profile.did,
86
+
createdAt: new Date().toISOString(),
87
+
},
88
+
},
89
+
})
90
+
if (ok)
91
+
profile.viewer
92
+
? (profile.viewer.following = data.uri)
93
+
: (profile.viewer = { following: data.uri })
94
+
}
95
+
} catch (e) {
96
+
console.error('toggleFollowRow failed', e)
97
+
profile.viewer.following = original
98
+
}
99
+
}
100
+
101
+
const isMutual = (p: AppBskyActorDefs.ProfileView) =>
102
+
!!(p.viewer?.following && p.viewer?.followedBy)
103
+
104
+
const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following
105
+
106
+
function followButtonLabel(p: AppBskyActorDefs.ProfileView) {
107
+
if (isMutual(p)) return 'Mutuals'
108
+
return isFollowingRow(p) ? 'Following' : 'Follow'
109
+
}
110
+
111
+
function setupObserver() {
112
+
if (!loadMoreTrigger.value) return
113
+
obs = new IntersectionObserver(
114
+
(entries) => {
115
+
for (const ent of entries) {
116
+
if (ent.isIntersecting && cursor.value && !isLoading.value) {
117
+
fetchFollows(false)
118
+
}
119
+
}
120
+
},
121
+
{ root: null, rootMargin: '200px', threshold: 0.1 },
122
+
)
123
+
obs.observe(loadMoreTrigger.value)
124
+
}
125
+
126
+
onMounted(async () => {
127
+
await fetchFollows(true)
128
+
setupObserver()
129
+
})
130
+
131
+
onBeforeUnmount(() => {
132
+
if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value)
133
+
obs = null
134
+
})
135
+
136
+
watch(
137
+
() => props.id,
138
+
() => {
139
+
fetchFollows(true)
140
+
},
141
+
)
142
+
</script>
143
+
144
+
<template>
145
+
<PageLayout title="Following" no-padding>
146
+
<div class="follows-list" role="list">
147
+
<div v-if="isLoading && follows.length === 0" class="skeletons">
148
+
<SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" />
149
+
</div>
150
+
151
+
<div v-else-if="isError" class="error-state">
152
+
<p>Failed to load follows - {{ isError }}</p>
153
+
<Button @click="fetchFollows(true)">Retry</Button>
154
+
</div>
155
+
156
+
<div v-else-if="isEmpty" class="empty-state">
157
+
<p>This user isn't following anyone yet.</p>
158
+
</div>
159
+
160
+
<AppLink
161
+
v-for="follow in follows"
162
+
:key="follow.did"
163
+
class="follow"
164
+
name="user-profile"
165
+
:params="{ id: follow.did }"
166
+
role="listitem"
167
+
>
168
+
<img
169
+
class="avatar"
170
+
:src="follow.avatar"
171
+
:alt="`${follow.displayName || follow.handle}'s avatar`"
172
+
loading="lazy"
173
+
/>
174
+
<div class="info">
175
+
<div class="top">
176
+
<div class="display-name">{{ follow.displayName || follow.handle }}</div>
177
+
<span class="handle">@{{ follow.handle }}</span>
178
+
<span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span>
179
+
<span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span>
180
+
</div>
181
+
<div class="meta">
182
+
<span v-if="follow.description" class="bio">{{ follow.description }}</span>
183
+
</div>
184
+
</div>
185
+
186
+
<button
187
+
class="follow-btn"
188
+
:class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }"
189
+
@click.stop.prevent="toggleFollowRow(follow)"
190
+
:aria-pressed="!!follow.viewer?.following"
191
+
:title="
192
+
isMutual(follow)
193
+
? 'Mutuals - click to unfollow'
194
+
: follow.viewer?.following
195
+
? 'Unfollow'
196
+
: 'Follow'
197
+
"
198
+
>
199
+
{{ followButtonLabel(follow) }}
200
+
</button>
201
+
</AppLink>
202
+
203
+
<div ref="loadMoreTrigger" class="load-more__sentinel" />
204
+
<div class="load-more__fallback" v-if="cursor && !isLoading">
205
+
<Button variant="ghost" @click="fetchFollows(false)">Load more</Button>
206
+
</div>
207
+
<div v-if="isLoading && follows.length > 0" class="loading-more">
208
+
<SkeletonLoader width="100%" height="72px" />
209
+
</div>
210
+
</div>
211
+
</PageLayout>
212
+
</template>
213
+
214
+
<style scoped lang="scss">
215
+
.follows-list {
216
+
/* padding: 1rem; */
217
+
display: flex;
218
+
flex-direction: column;
219
+
/* gap: 0.5rem; */
220
+
min-height: 200px;
221
+
}
222
+
</style>
+368
src/views/Profile/FollowingView.vue
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script lang="ts" setup>
2
+
import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue'
3
+
import type { ActorIdentifier } from '@atcute/lexicons'
4
+
import { AppBskyActorDefs } from '@atcute/bluesky'
5
+
import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth'
6
+
7
+
import PageLayout from '@/components/Navigation/PageLayout.vue'
8
+
import AppLink from '@/components/Navigation/AppLink.vue'
9
+
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
10
+
import Button from '@/components/UI/BaseButton.vue'
11
+
12
+
const props = defineProps<{ id: ActorIdentifier }>()
13
+
const auth = useAuthStore()
14
+
15
+
const follows = ref<AppBskyActorDefs.ProfileView[]>([])
16
+
const cursor = ref<string | null>(null)
17
+
const isLoading = ref(false)
18
+
const isError = ref<string | null>(null)
19
+
const loadMoreTrigger = ref<HTMLElement | null>(null)
20
+
let obs: IntersectionObserver | null = null
21
+
22
+
const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value)
23
+
24
+
async function fetchFollows(reset = false) {
25
+
if (isLoading.value) return
26
+
isLoading.value = true
27
+
isError.value = null
28
+
29
+
if (reset) {
30
+
follows.value = []
31
+
cursor.value = null
32
+
}
33
+
34
+
try {
35
+
const rpc = auth.getRpc()
36
+
const { data, ok } = await rpc.get('app.bsky.graph.getFollows', {
37
+
params: {
38
+
actor: props.id,
39
+
limit: 50,
40
+
cursor: cursor.value || undefined,
41
+
},
42
+
headers: { 'atproto-proxy': BSKY_APPVIEW },
43
+
})
44
+
45
+
if (!ok) {
46
+
isError.value = (data && data.error) || 'Failed to fetch follows'
47
+
return
48
+
}
49
+
50
+
follows.value.push(...(data.follows || []))
51
+
cursor.value = data.cursor || null
52
+
} catch (e) {
53
+
console.error('fetchFollows error', e)
54
+
isError.value = e instanceof Error ? e.message : 'Unknown error'
55
+
} finally {
56
+
isLoading.value = false
57
+
}
58
+
}
59
+
60
+
async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) {
61
+
if (!auth.isAuthenticated || !auth.session) return
62
+
const rpc = auth.getRpc()
63
+
const original = profile.viewer?.following
64
+
65
+
profile.viewer = profile.viewer || {}
66
+
if (original) {
67
+
profile.viewer.following = undefined
68
+
} else {
69
+
profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary`
70
+
}
71
+
72
+
try {
73
+
if (original) {
74
+
const rkey = original.split('/').pop()!
75
+
await rpc.post('com.atproto.repo.deleteRecord', {
76
+
input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey },
77
+
})
78
+
} else {
79
+
const { data, ok } = await rpc.post('com.atproto.repo.createRecord', {
80
+
input: {
81
+
collection: 'app.bsky.graph.follow',
82
+
repo: auth.session.info.sub,
83
+
record: {
84
+
$type: 'app.bsky.graph.follow',
85
+
subject: profile.did,
86
+
createdAt: new Date().toISOString(),
87
+
},
88
+
},
89
+
})
90
+
if (ok)
91
+
profile.viewer
92
+
? (profile.viewer.following = data.uri)
93
+
: (profile.viewer = { following: data.uri })
94
+
}
95
+
} catch (e) {
96
+
console.error('toggleFollowRow failed', e)
97
+
profile.viewer!.following = original
98
+
}
99
+
}
100
+
101
+
const isMutual = (p: AppBskyActorDefs.ProfileView) =>
102
+
!!(p.viewer?.following && p.viewer?.followedBy)
103
+
104
+
const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following
105
+
106
+
function followButtonLabel(p: AppBskyActorDefs.ProfileView) {
107
+
if (isMutual(p)) return 'Mutuals'
108
+
return isFollowingRow(p) ? 'Following' : 'Follow'
109
+
}
110
+
111
+
function setupObserver() {
112
+
if (!loadMoreTrigger.value) return
113
+
obs = new IntersectionObserver(
114
+
(entries) => {
115
+
for (const ent of entries) {
116
+
if (ent.isIntersecting && cursor.value && !isLoading.value) {
117
+
fetchFollows(false)
118
+
}
119
+
}
120
+
},
121
+
{ root: null, rootMargin: '200px', threshold: 0.1 },
122
+
)
123
+
obs.observe(loadMoreTrigger.value)
124
+
}
125
+
126
+
onMounted(async () => {
127
+
await fetchFollows(true)
128
+
setupObserver()
129
+
})
130
+
131
+
onBeforeUnmount(() => {
132
+
if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value)
133
+
obs = null
134
+
})
135
+
136
+
watch(
137
+
() => props.id,
138
+
() => {
139
+
fetchFollows(true)
140
+
},
141
+
)
142
+
</script>
143
+
144
+
<template>
145
+
<PageLayout title="Following" no-padding>
146
+
<div class="follows-list" role="list">
147
+
<div v-if="isLoading && follows.length === 0" class="skeletons">
148
+
<SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" />
149
+
</div>
150
+
151
+
<div v-else-if="isError" class="error-state">
152
+
<p>Failed to load follows - {{ isError }}</p>
153
+
<Button @click="fetchFollows(true)">Retry</Button>
154
+
</div>
155
+
156
+
<div v-else-if="isEmpty" class="empty-state">
157
+
<p>This user isn't following anyone yet.</p>
158
+
</div>
159
+
160
+
<AppLink
161
+
v-for="follow in follows"
162
+
:key="follow.did"
163
+
class="follow"
164
+
name="user-profile"
165
+
:params="{ id: follow.did }"
166
+
role="listitem"
167
+
>
168
+
<img
169
+
class="avatar"
170
+
:src="follow.avatar"
171
+
:alt="`${follow.displayName || follow.handle}'s avatar`"
172
+
loading="lazy"
173
+
/>
174
+
<div class="info">
175
+
<div class="top">
176
+
<div class="display-name">{{ follow.displayName || follow.handle }}</div>
177
+
<span class="handle">@{{ follow.handle }}</span>
178
+
<span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span>
179
+
<span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span>
180
+
</div>
181
+
<div class="meta">
182
+
<span v-if="follow.description" class="bio">{{ follow.description }}</span>
183
+
</div>
184
+
</div>
185
+
186
+
<button
187
+
class="follow-btn"
188
+
:class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }"
189
+
@click.stop.prevent="toggleFollowRow(follow)"
190
+
:aria-pressed="!!follow.viewer?.following"
191
+
:title="
192
+
isMutual(follow)
193
+
? 'Mutuals - click to unfollow'
194
+
: follow.viewer?.following
195
+
? 'Unfollow'
196
+
: 'Follow'
197
+
"
198
+
>
199
+
{{ followButtonLabel(follow) }}
200
+
</button>
201
+
</AppLink>
202
+
203
+
<div ref="loadMoreTrigger" class="load-more__sentinel" />
204
+
<div class="load-more__fallback" v-if="cursor && !isLoading">
205
+
<Button variant="ghost" @click="fetchFollows(false)">Load more</Button>
206
+
</div>
207
+
<div v-if="isLoading && follows.length > 0" class="loading-more">
208
+
<SkeletonLoader width="100%" height="72px" />
209
+
</div>
210
+
</div>
211
+
</PageLayout>
212
+
</template>
213
+
214
+
<style scoped lang="scss">
215
+
.follows-list {
216
+
/* padding: 1rem; */
217
+
display: flex;
218
+
flex-direction: column;
219
+
/* gap: 0.5rem; */
220
+
min-height: 200px;
221
+
}
222
+
223
+
.follow {
224
+
display: flex;
225
+
align-items: center;
226
+
gap: 0.75rem;
227
+
padding: 0.75rem;
228
+
/* border-radius: 0.75rem; */
229
+
/* background: hsla(var(--base) / 0.6); */
230
+
border-bottom: 1px solid hsla(var(--surface2) / 0.35);
231
+
text-decoration: none;
232
+
color: inherit;
233
+
234
+
&:hover {
235
+
background: hsla(var(--surface0) / 0.2);
236
+
}
237
+
238
+
.avatar {
239
+
width: 3rem;
240
+
height: 3rem;
241
+
border-radius: 50%;
242
+
object-fit: cover;
243
+
flex: 0 0 auto;
244
+
}
245
+
246
+
.info {
247
+
flex: 1;
248
+
display: flex;
249
+
flex-direction: column;
250
+
overflow: hidden;
251
+
252
+
.top {
253
+
display: flex;
254
+
align-items: center;
255
+
gap: 0.5rem;
256
+
min-width: 0;
257
+
font-size: 0.9rem;
258
+
259
+
.display-name {
260
+
font-weight: 700;
261
+
white-space: nowrap;
262
+
text-overflow: ellipsis;
263
+
overflow: hidden;
264
+
}
265
+
266
+
.handle {
267
+
color: hsl(var(--subtext1));
268
+
font-size: 0.9rem;
269
+
}
270
+
271
+
.pronouns {
272
+
color: hsl(var(--subtext0));
273
+
white-space: nowrap;
274
+
}
275
+
276
+
.badge {
277
+
margin-left: auto;
278
+
font-size: 0.72rem;
279
+
padding: 0.15rem 0.4rem;
280
+
border-radius: 999px;
281
+
background: hsla(var(--surface2) / 0.45);
282
+
color: hsl(var(--subtext1));
283
+
font-weight: 600;
284
+
}
285
+
286
+
.badge.mutual {
287
+
background: hsla(var(--accent) / 0.12);
288
+
color: hsl(var(--accent));
289
+
}
290
+
}
291
+
292
+
.meta {
293
+
color: hsl(var(--subtext0));
294
+
font-size: 0.88rem;
295
+
margin-top: 0.25rem;
296
+
overflow: hidden;
297
+
text-overflow: ellipsis;
298
+
white-space: nowrap;
299
+
}
300
+
}
301
+
302
+
.follow-btn {
303
+
flex: 0 0 auto;
304
+
padding: 0.35rem 0.75rem;
305
+
border-radius: 999px;
306
+
border: none;
307
+
cursor: pointer;
308
+
color: hsl(var(--crust));
309
+
font-weight: 700;
310
+
311
+
background: hsla(var(--accent) / 0.85);
312
+
313
+
&:hover {
314
+
background: hsla(var(--accent) / 1);
315
+
}
316
+
&:active {
317
+
background: hsla(var(--accent) / 0.75);
318
+
}
319
+
320
+
&.following {
321
+
background: transparent;
322
+
border: 1px solid hsla(var(--surface2) / 0.75);
323
+
color: hsl(var(--text));
324
+
&:hover {
325
+
background: hsla(var(--surface0) / 0.9);
326
+
}
327
+
&:active {
328
+
background: hsla(var(--surface0) / 0.5);
329
+
}
330
+
}
331
+
/* &.mutual {
332
+
background: hsla(var(--accent) / 0.85);
333
+
color: hsl(var(--base));
334
+
border: 1px solid hsla(var(--surface2) / 0.45);
335
+
&:hover {
336
+
background: hsla(var(--accent) / 1);
337
+
}
338
+
&:active {
339
+
background: hsla(var(--accent) / 0.6);
340
+
}
341
+
} */
342
+
}
343
+
}
344
+
345
+
.empty-state,
346
+
.error-state {
347
+
padding: 2rem;
348
+
display: flex;
349
+
flex-direction: column;
350
+
align-items: center;
351
+
gap: 0.75rem;
352
+
color: hsl(var(--subtext0));
353
+
}
354
+
355
+
.load_more {
356
+
&__sentinel {
357
+
height: 1px;
358
+
359
+
&__fallback {
360
+
display: flex;
361
+
justify-content: center;
362
+
padding: 1rem;
363
+
margin-top: 1rem;
364
+
margin-bottom: 8rem;
365
+
}
366
+
}
367
+
}
368
+
</style>
+111
src/views/Profile/FollowsView.vue
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<script setup lang="ts">
2
+
import { computed, ref, onMounted } from 'vue'
3
+
import type { AppBskyActorDefs } from '@atcute/bluesky'
4
+
5
+
import { usePagedProfiles } from '@/composables/usePagedProfiles'
6
+
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
7
+
8
+
import ProfileRow from '@/components/Profile/ProfileRow.vue'
9
+
import PageLayout from '@/components/Navigation/PageLayout.vue'
10
+
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
11
+
import Button from '@/components/UI/BaseButton.vue'
12
+
13
+
import { useAuthStore, BSKY_APPVIEW } from '@/stores/auth'
14
+
import type { ActorIdentifier } from '@atcute/lexicons'
15
+
16
+
const props = defineProps<{
17
+
id?: string
18
+
routeName?: string
19
+
}>()
20
+
21
+
const mode = computed(() => {
22
+
if (props.routeName === 'user-followers') return 'followers'
23
+
if (props.routeName === 'user-follows') return 'follows'
24
+
return 'follows'
25
+
})
26
+
27
+
const auth = useAuthStore()
28
+
29
+
const {
30
+
items: follows,
31
+
cursor,
32
+
loading,
33
+
error,
34
+
fetchPage,
35
+
} = usePagedProfiles<AppBskyActorDefs.ProfileView>()
36
+
const sentinel = ref<HTMLElement | null>(null)
37
+
38
+
const fetcher = async (c?: string | null) => {
39
+
const rpc = auth.getRpc()
40
+
console.log(mode.value)
41
+
const endpoint =
42
+
mode.value === 'followers' ? 'app.bsky.graph.getFollowers' : 'app.bsky.graph.getFollows'
43
+
44
+
const { data, ok } = await rpc.get(endpoint, {
45
+
params: { actor: props.id as ActorIdentifier, limit: 50, cursor: c ?? undefined },
46
+
headers: { 'atproto-proxy': BSKY_APPVIEW },
47
+
})
48
+
if (!ok) throw new Error((data && data.error) || 'Failed')
49
+
50
+
console.log(props)
51
+
52
+
return {
53
+
// @ts-expect-error: its ok typescript. ..
54
+
items: mode.value === 'followers' ? data.followers || [] : data.follows || [],
55
+
cursor: data.cursor ?? null,
56
+
}
57
+
}
58
+
59
+
const { setup } = useInfiniteScroll(sentinel, () => {
60
+
if (cursor.value && !loading.value) fetchPage(fetcher, false)
61
+
})
62
+
63
+
onMounted(async () => {
64
+
await fetchPage(fetcher, true)
65
+
setup()
66
+
})
67
+
</script>
68
+
69
+
<template>
70
+
<PageLayout :title="mode === 'followers' ? 'Followers' : 'Following'" noPadding>
71
+
<div class="follows-list" role="list">
72
+
<template v-if="loading && follows.length === 0">
73
+
<SkeletonLoader v-for="n in 6" :key="n" />
74
+
</template>
75
+
<div v-else-if="error">
76
+
{{ error }} <Button @click="() => fetchPage(fetcher, true)">Retry</Button>
77
+
</div>
78
+
<ProfileRow v-for="profile in follows" :key="profile.did" :profile="profile" />
79
+
<div ref="sentinel" style="height: 1px"></div>
80
+
<div v-if="cursor && !loading">
81
+
<Button @click="() => fetchPage(fetcher, false)">Load more</Button>
82
+
</div>
83
+
</div>
84
+
</PageLayout>
85
+
</template>
86
+
87
+
<style lang="scss" scoped>
88
+
.empty-state,
89
+
.error-state {
90
+
padding: 2rem;
91
+
display: flex;
92
+
flex-direction: column;
93
+
align-items: center;
94
+
gap: 0.75rem;
95
+
color: hsl(var(--subtext0));
96
+
}
97
+
98
+
.load_more {
99
+
&__sentinel {
100
+
height: 1px;
101
+
102
+
&__fallback {
103
+
display: flex;
104
+
justify-content: center;
105
+
padding: 1rem;
106
+
margin-top: 1rem;
107
+
margin-bottom: 8rem;
108
+
}
109
+
}
110
+
}
111
+
</style>
+3
src/views/SettingsPage.vue
···
1
<script lang="ts" setup>
2
import { ref, computed } from 'vue'
0
3
import { useThemeStore, AccentColours } from '@/stores/theme'
4
import { useNavigationStore } from '@/stores/navigation'
5
import { useAuthStore } from '@/stores/auth'
···
159
>
160
</ListItem>
161
</ListGroup>
0
0
162
163
<!-- modals -->
164
<Modal v-model:open="showThemeModal" title="Select Theme" width="640px">
···
1
<script lang="ts" setup>
2
import { ref, computed } from 'vue'
3
+
4
import { useThemeStore, AccentColours } from '@/stores/theme'
5
import { useNavigationStore } from '@/stores/navigation'
6
import { useAuthStore } from '@/stores/auth'
···
160
>
161
</ListItem>
162
</ListGroup>
163
+
164
+
<ListGroup title="Developer"> </ListGroup>
165
166
<!-- modals -->
167
<Modal v-model:open="showThemeModal" title="Select Theme" width="640px">
+9
-5
src/views/UserProfile.vue
src/views/Profile/ProfileView.vue
···
20
import Button from '@/components/UI/BaseButton.vue'
21
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
22
import SVG from '@/components/UI/SVG.vue'
0
23
import BluebellLogo from '@/assets/icons/bluebell.svg?raw'
24
import type { ActorIdentifier } from '@atcute/lexicons'
0
25
26
const props = defineProps<{ id: string }>()
27
···
266
<component :is="isFollowing ? IconRemoveRounded : IconAddRounded" />
267
{{ isFollowing ? 'Following' : 'Follow' }}
268
</Button>
269
-
<Button variant="secondary" icon size="sm" flat><IconMoreHoriz /></Button>
0
0
270
</div>
271
</div>
272
···
283
</div>
284
285
<div class="stats-row">
286
-
<div class="stat-item">
287
<span class="stat-val">{{ formatCount(profile.followersCount) }}</span>
288
<span class="stat-label">Followers</span>
289
-
</div>
290
-
<div class="stat-item">
291
<span class="stat-val">{{ formatCount(profile.followsCount) }}</span>
292
<span class="stat-label">Following</span>
293
-
</div>
294
<div class="stat-item">
295
<span class="stat-val">{{ formatCount(profile.postsCount) }}</span>
296
<span class="stat-label">Posts</span>
···
20
import Button from '@/components/UI/BaseButton.vue'
21
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
22
import SVG from '@/components/UI/SVG.vue'
23
+
24
import BluebellLogo from '@/assets/icons/bluebell.svg?raw'
25
import type { ActorIdentifier } from '@atcute/lexicons'
26
+
import AppLink from '@/components/Navigation/AppLink.vue'
27
28
const props = defineProps<{ id: string }>()
29
···
268
<component :is="isFollowing ? IconRemoveRounded : IconAddRounded" />
269
{{ isFollowing ? 'Following' : 'Follow' }}
270
</Button>
271
+
<Button variant="secondary" icon size="sm" flat>
272
+
<IconMoreHoriz />
273
+
</Button>
274
</div>
275
</div>
276
···
287
</div>
288
289
<div class="stats-row">
290
+
<AppLink class="stat-item" name="user-followers" :params="{ id: profile.did }">
291
<span class="stat-val">{{ formatCount(profile.followersCount) }}</span>
292
<span class="stat-label">Followers</span>
293
+
</AppLink>
294
+
<AppLink class="stat-item" name="user-follows" :params="{ id: profile.did }">
295
<span class="stat-val">{{ formatCount(profile.followsCount) }}</span>
296
<span class="stat-label">Following</span>
297
+
</AppLink>
298
<div class="stat-item">
299
<span class="stat-val">{{ formatCount(profile.postsCount) }}</span>
300
<span class="stat-label">Posts</span>