Compare commits

...

3 Commits

Author SHA1 Message Date
Julien Calixte
e39ac32e43 feat(public-notes): place user pill in header instead of tabs row
Moves the user identity element out of the tabs bar (where it was
absolutely positioned over the tabs) into a proper header row next to
the home button, and switches it from SignInAtproto to the shared
UserPill so the same atproto-first identity shows everywhere.
2026-05-17 14:15:22 +02:00
Julien Calixte
a7ea2ce9cf refactor(profile): adopt UserPill and ProfileModal in home and repos
Replaces the duplicated profile-chip button and profile_modal dialog in
WelcomeWorld and RepoList with the shared components, and removes the
now-orphaned profile-chip, hw-modal, hw-ms-label, hw-btn-ghost, and
hw-rule styles. As a side effect the displayed identity now prefers the
ATProto handle over the GitHub username.
2026-05-17 14:15:18 +02:00
Julien Calixte
1bf09a6629 feat(profile): add shared UserPill and ProfileModal components
UserPill renders an avatar + display name button, preferring the ATProto
handle over the GitHub username, and emits @click so each view decides
what to open. ProfileModal extracts the previously duplicated
profile_modal dialog with self-contained DaisyUI-token styling.
2026-05-17 14:15:13 +02:00
5 changed files with 297 additions and 361 deletions

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed } from "vue"
import SignInAtproto from "@/components/SignInAtproto.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
const { accessToken } = useGitHubLogin()
const isGitHubLoggedIn = computed(() => !!accessToken.value)
</script>
<template>
<dialog id="profile_modal" class="modal profile-modal">
<div class="modal-box profile-modal-box">
<div class="profile-modal-head">
<h3>Profile</h3>
<form method="dialog">
<button class="profile-modal-x" aria-label="close">×</button>
</form>
</div>
<div class="profile-modal-section">
<div class="profile-modal-label">Bluesky / ATProto</div>
<sign-in-atproto :with-sign-out="true" />
</div>
<hr class="profile-modal-rule" />
<div class="profile-modal-section">
<div class="profile-modal-label">GitHub</div>
<sign-in-github />
<router-link
v-if="isGitHubLoggedIn"
:to="{ name: 'RepoList' }"
class="profile-modal-link"
>Manage your repos</router-link
>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</template>
<style scoped lang="scss">
.profile-modal {
--pm-rule: color-mix(
in oklch,
var(--color-base-content) 12%,
var(--color-base-200)
);
--pm-ink: var(--color-base-content);
--pm-ink-soft: color-mix(
in oklch,
var(--color-base-content) 65%,
var(--color-base-200)
);
--pm-ink-faint: color-mix(
in oklch,
var(--color-base-content) 38%,
var(--color-base-200)
);
--pm-accent: #e36598;
--pm-accent-wash: color-mix(in oklch, #e36598 12%, var(--color-base-200));
--pm-accent-deep: color-mix(
in oklch,
#e36598 75%,
var(--color-base-content)
);
}
.profile-modal-box {
background: var(--color-base-200);
border: 1px solid var(--pm-rule);
border-radius: 6px;
padding: 1.5rem;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
color: var(--pm-ink);
}
.profile-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
font-size: 1.3rem;
margin: 0;
font-weight: 600;
}
}
.profile-modal-x {
border: 0;
background: transparent;
font-size: 1.5rem;
cursor: pointer;
color: var(--pm-ink-soft);
line-height: 1;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--pm-accent-deep);
}
}
.profile-modal-section {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
}
.profile-modal-label {
display: block;
width: 100%;
font-size: 0.72rem;
letter-spacing: 0.14em;
color: var(--pm-ink-faint);
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.profile-modal-rule {
border: 0;
border-top: 1px solid var(--pm-rule);
margin: 1.25rem 0;
}
.profile-modal-link {
font-size: 16px;
padding: 0.55rem 1rem;
border-radius: 2px;
border: 1px solid var(--pm-rule);
background: transparent;
color: var(--pm-ink-soft);
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
&:hover {
background: var(--pm-accent-wash);
border-color: var(--pm-accent);
color: var(--pm-accent-deep);
}
}
</style>

111
src/components/UserPill.vue Normal file
View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed } from "vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
const { username, accessToken } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
defineEmits<{
click: []
}>()
const isGitHubLoggedIn = computed(() => !!accessToken.value)
const isAnyUserLoggedIn = computed(
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
)
const displayUsername = computed(() => handle.value || username.value || "")
const displayInitial = computed(() =>
(displayUsername.value[0] || "?").toUpperCase()
)
</script>
<template>
<button
v-if="isAnyUserLoggedIn"
class="profile-chip"
type="button"
@click="$emit('click')"
>
<img
v-if="isATProtoLoggedIn && avatarUrl"
:src="avatarUrl"
class="profile-avatar-small"
alt="Profile"
/>
<span v-else class="profile-avatar-small profile-avatar-initial">
{{ displayInitial }}
</span>
<span class="profile-name">{{ displayUsername }}</span>
</button>
<button
v-else
class="profile-chip profile-chip--ghost"
type="button"
@click="$emit('click')"
>
Sign in
</button>
</template>
<style scoped lang="scss">
.profile-chip {
--pill-rule: color-mix(
in oklch,
var(--color-base-content) 12%,
var(--color-base-200)
);
--pill-accent: #e36598;
--pill-accent-wash: color-mix(
in oklch,
#e36598 12%,
var(--color-base-200)
);
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
border-radius: 999px;
border: 1px solid var(--pill-rule);
background: transparent;
cursor: pointer;
color: var(--color-base-content);
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: var(--pill-accent);
background: var(--pill-accent-wash);
}
&.profile-chip--ghost {
padding: 0.35rem 0.9rem;
font-size: 0.95rem;
}
}
.profile-avatar-small {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.profile-avatar-initial {
background: var(--pill-accent, #e36598);
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
}
.profile-name {
font-size: 0.95rem;
}
</style>

View File

@@ -4,9 +4,9 @@ import { getHex } from "pastel-color"
import { computed, onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import SignInAtproto from "@/components/SignInAtproto.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import ProfileModal from "@/components/ProfileModal.vue"
import ThemeSwap from "@/components/ThemeSwap.vue"
import UserPill from "@/components/UserPill.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useForm } from "@/hooks/useForm.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
@@ -18,7 +18,7 @@ import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
import { slugify } from "@/utils/slugify"
const { username, accessToken } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
const { isLoggedIn: isATProtoLoggedIn, handle } = useATProtoLogin()
const { userInput, repoInput, submit } = useForm()
const { savedFavoriteRepos } = useFavoriteRepos()
const { lastVisitedRepos } = useLastVisitedRepos()
@@ -29,10 +29,11 @@ const isGitHubLoggedIn = computed(() => !!accessToken.value)
const isAnyUserLoggedIn = computed(
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
)
const displayUsername = computed(() => username.value || handle.value || "")
const displayInitial = computed(() =>
(displayUsername.value[0] || "?").toUpperCase()
)
const displayUsername = computed(() => handle.value || username.value || "")
const openProfile = () => {
;(document.getElementById("profile_modal") as HTMLDialogElement)?.showModal()
}
const tileStyle = (seed: string) => {
const bg = getHex(seed)
@@ -150,29 +151,7 @@ onMounted(() => {
class="navlink"
>Getting&nbsp;started</router-link
>
<button
v-if="isAnyUserLoggedIn"
class="profile-chip"
onclick="profile_modal.showModal()"
>
<img
v-if="isATProtoLoggedIn && avatarUrl"
:src="avatarUrl"
class="profile-avatar-small"
alt="Profile"
/>
<span v-else class="profile-avatar-small profile-avatar-initial">
{{ displayInitial }}
</span>
<span class="profile-name">{{ displayUsername }}</span>
</button>
<button
v-else
class="hw-btn hw-btn-ghost"
onclick="profile_modal.showModal()"
>
Sign in
</button>
<UserPill @click="openProfile" />
</div>
</nav>
@@ -846,35 +825,7 @@ onMounted(() => {
</div>
</footer>
<!-- ── Profile modal ──────────────────────────────────── -->
<dialog id="profile_modal" class="modal hw-modal">
<div class="modal-box hw-modal-box">
<div class="hw-modal-head">
<h3>Profile</h3>
<form method="dialog">
<button class="hw-modal-x" aria-label="close">×</button>
</form>
</div>
<div class="hw-modal-section">
<div class="hw-ms-label mono">Bluesky / ATProto</div>
<sign-in-atproto :with-sign-out="true" />
</div>
<hr class="hw-rule" />
<div class="hw-modal-section">
<div class="hw-ms-label mono">GitHub</div>
<sign-in-github />
<router-link
v-if="isGitHubLoggedIn"
:to="{ name: 'RepoList' }"
class="hw-btn hw-btn-ghost"
>Manage your repos</router-link
>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
<ProfileModal />
</div>
</template>
@@ -999,17 +950,6 @@ main {
}
}
.hw-btn-ghost {
border-color: var(--hw-rule);
color: var(--hw-ink-soft);
&:hover {
background: var(--hw-pink-wash);
border-color: var(--hw-pink);
color: var(--hw-pink-deep);
}
}
/* ── Utility classes ────────────────────────────────────────── */
.mono {
font-family: var(--hw-mono);
@@ -1029,12 +969,6 @@ main {
color: var(--hw-pink-deep);
}
.hw-rule {
border: 0;
border-top: 1px solid var(--hw-rule);
margin: 1.25rem 0;
}
/* ── Top nav ────────────────────────────────────────────────── */
.topnav {
display: flex;
@@ -1086,50 +1020,6 @@ main {
}
}
.profile-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
border-radius: 999px;
border: 1px solid var(--hw-rule);
background: transparent;
cursor: pointer;
font-family: var(--hw-serif);
color: var(--hw-ink);
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: var(--hw-pink);
background: var(--hw-pink-wash);
}
}
.profile-avatar-small {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.profile-avatar-initial {
background: var(--hw-pink);
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--hw-serif);
font-weight: 600;
font-size: 0.9rem;
}
.profile-name {
font-size: 0.95rem;
}
/* ── GitHub form ────────────────────────────────────────────── */
.gh-form {
display: flex;
@@ -2282,62 +2172,6 @@ img {
color: var(--hw-ink-faint);
}
/* ── Profile modal ──────────────────────────────────────────── */
.hw-modal .hw-modal-box {
background: var(--hw-paper);
border: 1px solid var(--hw-rule);
border-radius: 6px;
padding: 1.5rem;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
color: var(--hw-ink);
font-family: var(--hw-serif);
}
.hw-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
font-family: var(--hw-serif);
font-size: 1.3rem;
margin: 0;
font-weight: 600;
}
}
.hw-modal-x {
border: 0;
background: transparent;
font-size: 1.5rem;
cursor: pointer;
color: var(--hw-ink-soft);
line-height: 1;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--hw-pink-deep);
}
}
.hw-modal-section {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
}
.hw-ms-label {
display: block;
width: 100%;
font-size: 0.72rem;
letter-spacing: 0.14em;
color: var(--hw-ink-faint);
margin-bottom: 0.25rem;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-ed-inner {

View File

@@ -3,8 +3,9 @@ import { computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import HomeButton from "@/components/HomeButton.vue"
import ProfileModal from "@/components/ProfileModal.vue"
import PublicNoteList from "@/components/PublicNoteList.vue"
import SignInAtproto from "@/components/SignInAtproto.vue"
import UserPill from "@/components/UserPill.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useFollowingNoteList } from "@/hooks/useFollowingNoteList.hook"
import { useFollows } from "@/hooks/useFollows.hook"
@@ -29,13 +30,18 @@ const followingEnabled = computed(() => tab.value === "following")
const all = usePublicNoteList()
const following = useFollowingNoteList(follows, followingEnabled)
const openProfile = () => {
;(document.getElementById("profile_modal") as HTMLDialogElement)?.showModal()
}
</script>
<template>
<main class="public-note-list-view">
<div class="header">
<home-button class="home-button" />
</div>
<header class="header">
<home-button />
<user-pill @click="openProfile" />
</header>
<div v-if="isLoggedIn" role="tablist" class="tabs tabs-border">
<a
@@ -52,7 +58,6 @@ const following = useFollowingNoteList(follows, followingEnabled)
@click="tab = 'following'"
>Following</a
>
<sign-in-atproto class="handle" />
</div>
<PublicNoteList
@@ -104,20 +109,12 @@ const following = useFollowingNoteList(follows, followingEnabled)
<div v-else class="skeleton h-4 w-20"></div>
</template>
</PublicNoteList>
<profile-modal />
</main>
</template>
<style scoped lang="scss">
.tabs {
position: relative;
}
.handle {
position: absolute;
right: 0;
align-self: center;
}
.public-note-list-view {
display: flex;
flex: 1;
@@ -126,10 +123,11 @@ const following = useFollowingNoteList(follows, followingEnabled)
padding-right: 1rem;
.header {
margin: 0.5rem auto 0;
margin-top: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
h1 {

View File

@@ -4,8 +4,9 @@ import fontColorContrast from "font-color-contrast"
import { getHex } from "pastel-color"
import { computed, ref } from "vue"
import SignInAtproto from "@/components/SignInAtproto.vue"
import ProfileModal from "@/components/ProfileModal.vue"
import SignInGithub from "@/components/SignInGithub.vue"
import UserPill from "@/components/UserPill.vue"
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
import { useRepos } from "@/hooks/useRepos.hook"
@@ -13,7 +14,7 @@ import { useRepoList } from "@/modules/repo/hooks/useRepoList.hook"
import type { RepoBase } from "@/modules/repo/interfaces/RepoBase"
const { username, accessToken } = useGitHubLogin()
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
const { isLoggedIn: isATProtoLoggedIn } = useATProtoLogin()
const { isReady, hasCredentialError } = useRepos()
const {
favoriteRepos,
@@ -28,10 +29,10 @@ const isGitHubLoggedIn = computed(() => !!accessToken.value)
const isAnyUserLoggedIn = computed(
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
)
const displayUsername = computed(() => username.value || handle.value || "")
const displayInitial = computed(() =>
(displayUsername.value[0] || "?").toUpperCase()
)
const openProfile = () => {
;(document.getElementById("profile_modal") as HTMLDialogElement)?.showModal()
}
const filterQuery = ref("")
const normalizedQuery = computed(() => filterQuery.value.trim().toLowerCase())
@@ -102,29 +103,7 @@ const isStarred = (repo: RepoBase) => favoriteCheckboxes.value.includes(repo.id)
class="navlink"
>Getting&nbsp;started</router-link
>
<button
v-if="isAnyUserLoggedIn"
class="profile-chip"
onclick="profile_modal.showModal()"
>
<img
v-if="isATProtoLoggedIn && avatarUrl"
:src="avatarUrl"
class="profile-avatar-small"
alt="Profile"
/>
<span v-else class="profile-avatar-small profile-avatar-initial">
{{ displayInitial }}
</span>
<span class="profile-name">{{ displayUsername }}</span>
</button>
<button
v-else
class="hw-btn hw-btn-ghost"
onclick="profile_modal.showModal()"
>
Sign in
</button>
<UserPill @click="openProfile" />
</div>
</nav>
@@ -344,29 +323,7 @@ const isStarred = (repo: RepoBase) => favoriteCheckboxes.value.includes(repo.id)
</template>
</main>
<!-- Profile modal -->
<dialog id="profile_modal" class="modal hw-modal">
<div class="modal-box hw-modal-box">
<div class="hw-modal-head">
<h3>Profile</h3>
<form method="dialog">
<button class="hw-modal-x" aria-label="close">×</button>
</form>
</div>
<div class="hw-modal-section">
<div class="hw-ms-label mono">Bluesky / ATProto</div>
<sign-in-atproto :with-sign-out="true" />
</div>
<hr class="hw-rule" />
<div class="hw-modal-section">
<div class="hw-ms-label mono">GitHub</div>
<sign-in-github />
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
<ProfileModal />
</div>
</template>
@@ -467,27 +424,10 @@ main {
}
}
.hw-btn-ghost {
border-color: var(--hw-rule);
color: var(--hw-ink-soft);
&:hover {
background: var(--hw-pink-wash);
border-color: var(--hw-pink);
color: var(--hw-pink-deep);
}
}
.mono {
font-family: var(--hw-mono);
}
.hw-rule {
border: 0;
border-top: 1px solid var(--hw-rule);
margin: 1.25rem 0;
}
/* ── Top nav ───────────────────────────────────────────────── */
.topnav {
display: flex;
@@ -541,50 +481,6 @@ main {
}
}
.profile-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
border-radius: 999px;
border: 1px solid var(--hw-rule);
background: transparent;
cursor: pointer;
font-family: var(--hw-serif);
color: var(--hw-ink);
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: var(--hw-pink);
background: var(--hw-pink-wash);
}
}
.profile-avatar-small {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.profile-avatar-initial {
background: var(--hw-pink);
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--hw-serif);
font-weight: 600;
font-size: 0.9rem;
}
.profile-name {
font-size: 0.95rem;
}
/* ── Hero ──────────────────────────────────────────────────── */
.rl-hero {
padding: 3rem 2rem 1rem;
@@ -1015,62 +911,6 @@ main {
text-transform: lowercase;
}
/* ── Profile modal ─────────────────────────────────────────── */
.hw-modal .hw-modal-box {
background: var(--hw-paper);
border: 1px solid var(--hw-rule);
border-radius: 6px;
padding: 1.5rem;
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
color: var(--hw-ink);
font-family: var(--hw-serif);
}
.hw-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
font-family: var(--hw-serif);
font-size: 1.3rem;
margin: 0;
font-weight: 600;
}
}
.hw-modal-x {
border: 0;
background: transparent;
font-size: 1.5rem;
cursor: pointer;
color: var(--hw-ink-soft);
line-height: 1;
padding: 0.25rem 0.5rem;
&:hover {
color: var(--hw-pink-deep);
}
}
.hw-modal-section {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
}
.hw-ms-label {
display: block;
width: 100%;
font-size: 0.72rem;
letter-spacing: 0.14em;
color: var(--hw-ink-faint);
margin-bottom: 0.25rem;
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 900px) {
.favs-grid {