1112 lines
27 KiB
Vue
1112 lines
27 KiB
Vue
<script lang="ts" setup>
|
||
import { vInfiniteScroll } from "@vueuse/components"
|
||
import fontColorContrast from "font-color-contrast"
|
||
import { getHex } from "pastel-color"
|
||
import { computed, ref } from "vue"
|
||
|
||
import SignInAtproto from "@/components/SignInAtproto.vue"
|
||
import SignInGithub from "@/components/SignInGithub.vue"
|
||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||
import { useRepos } from "@/hooks/useRepos.hook"
|
||
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 { isReady, hasCredentialError } = useRepos()
|
||
const {
|
||
favoriteRepos,
|
||
otherRepos,
|
||
favoriteCheckboxes,
|
||
toggleCheckbox,
|
||
canLoadMore,
|
||
loadMore
|
||
} = useRepoList()
|
||
|
||
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 filterQuery = ref("")
|
||
const normalizedQuery = computed(() => filterQuery.value.trim().toLowerCase())
|
||
const matchesQuery = (name: string) =>
|
||
normalizedQuery.value === "" ||
|
||
name.toLowerCase().includes(normalizedQuery.value)
|
||
|
||
const filteredFavoriteRepos = computed<RepoBase[]>(() =>
|
||
favoriteRepos.value.filter((r) => matchesQuery(r.name))
|
||
)
|
||
const filteredOtherRepos = computed<RepoBase[]>(() =>
|
||
otherRepos.value.filter((r) => matchesQuery(r.name))
|
||
)
|
||
|
||
const groupedOtherRepos = computed<
|
||
Array<{ letter: string; repos: RepoBase[] }>
|
||
>(() => {
|
||
const groups = new Map<string, RepoBase[]>()
|
||
const sorted = [...filteredOtherRepos.value].sort((a, b) =>
|
||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
||
)
|
||
for (const repo of sorted) {
|
||
const ch = repo.name[0]?.toUpperCase() ?? "#"
|
||
const letter = /[A-Z]/.test(ch) ? ch : "#"
|
||
if (!groups.has(letter)) groups.set(letter, [])
|
||
groups.get(letter)!.push(repo)
|
||
}
|
||
return [...groups.entries()].map(([letter, repos]) => ({ letter, repos }))
|
||
})
|
||
|
||
const tileStyle = (seed: string) => {
|
||
const bg = getHex(seed)
|
||
return { backgroundColor: bg, color: fontColorContrast(bg) }
|
||
}
|
||
|
||
const isStarred = (repo: RepoBase) => favoriteCheckboxes.value.includes(repo.id)
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="repo-list-page"
|
||
v-infinite-scroll="[loadMore, { canLoadMore: () => canLoadMore }]"
|
||
>
|
||
<!-- ── Top Nav ─────────────────────────────────────────── -->
|
||
<nav class="topnav">
|
||
<div class="topnav-left">
|
||
<router-link :to="{ name: 'Home' }" class="brand">
|
||
<img
|
||
src="/favicon.png"
|
||
alt="Remanso"
|
||
class="brand-mark"
|
||
width="28"
|
||
height="28"
|
||
/>
|
||
<span class="brand-word">Remanso</span>
|
||
</router-link>
|
||
</div>
|
||
<div class="topnav-right">
|
||
<router-link :to="{ name: 'Home' }" class="navlink">Home</router-link>
|
||
<router-link :to="{ name: 'PublicNoteListView' }" class="navlink"
|
||
>Public notes</router-link
|
||
>
|
||
<router-link
|
||
:to="{
|
||
name: 'FluxNoteView',
|
||
params: { user: 'remanso-space', repo: 'getting-started' }
|
||
}"
|
||
class="navlink"
|
||
>Getting 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>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- ── Main ────────────────────────────────────────────── -->
|
||
<main>
|
||
<!-- Hero -->
|
||
<section class="rl-hero">
|
||
<div class="rl-hero-inner">
|
||
<div class="eyebrow mono">manage your repos</div>
|
||
<h1 class="li-h1">Your repositories</h1>
|
||
<p class="li-sub">
|
||
<span class="mono"
|
||
>{{ favoriteRepos.length }} starred
|
||
<span class="li-dot">·</span>
|
||
{{ otherRepos.length }} in your account</span
|
||
>
|
||
<span class="li-dot">·</span>
|
||
<router-link :to="{ name: 'Home' }" class="li-sub-link"
|
||
>← back to home</router-link
|
||
>
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Filter -->
|
||
<section class="rl-filter-wrap" v-if="isReady && !hasCredentialError">
|
||
<label class="rl-filter">
|
||
<span class="rl-filter-prefix mono">filter</span>
|
||
<input
|
||
v-model="filterQuery"
|
||
type="text"
|
||
class="rl-filter-input"
|
||
placeholder="repo name…"
|
||
spellcheck="false"
|
||
autocapitalize="off"
|
||
/>
|
||
<button
|
||
v-if="filterQuery"
|
||
type="button"
|
||
class="rl-clear-btn"
|
||
aria-label="Clear filter"
|
||
@click="filterQuery = ''"
|
||
>
|
||
×
|
||
</button>
|
||
</label>
|
||
</section>
|
||
|
||
<!-- Loading state -->
|
||
<section class="rl-section" v-if="!isReady">
|
||
<div class="section-label mono">§ loading repos</div>
|
||
<div class="favs-grid">
|
||
<div
|
||
v-for="i in 6"
|
||
:key="i"
|
||
class="repo-tile repo-tile--skel"
|
||
aria-hidden="true"
|
||
>
|
||
<span class="skel skel-handle" />
|
||
<span class="skel skel-title" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Credential-error state -->
|
||
<section class="rl-section" v-else-if="hasCredentialError">
|
||
<div class="rl-card">
|
||
<div class="eyebrow mono">sign in needed</div>
|
||
<h2 class="rl-card-title">GitHub credentials expired</h2>
|
||
<p class="rl-card-body">
|
||
Your GitHub session is no longer valid. Sign back in to load and
|
||
manage your repositories.
|
||
</p>
|
||
<sign-in-github />
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Repos -->
|
||
<template v-else>
|
||
<!-- Favorites -->
|
||
<section class="rl-section">
|
||
<div class="section-label mono">§ starred</div>
|
||
<p
|
||
v-if="filteredFavoriteRepos.length === 0 && filterQuery"
|
||
class="rl-empty"
|
||
>
|
||
nothing matches “{{ filterQuery }}” in your starred
|
||
repos.
|
||
</p>
|
||
<p v-else-if="favoriteRepos.length === 0" class="rl-empty">
|
||
no starred repos yet — tap a ★ below to pin one here.
|
||
</p>
|
||
<div v-else class="favs-grid">
|
||
<router-link
|
||
v-for="repo in filteredFavoriteRepos"
|
||
:key="repo.id"
|
||
class="repo-tile"
|
||
:style="tileStyle(`${repo.name}-${username}`)"
|
||
:to="{
|
||
name: 'FluxNoteView',
|
||
params: { user: username, repo: repo.name }
|
||
}"
|
||
>
|
||
<div class="tile-top">
|
||
<span class="tile-owner mono">{{ username }}/</span>
|
||
<span class="tile-name">{{ repo.name }}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="tile-star tile-star--on"
|
||
aria-pressed="true"
|
||
aria-label="Unstar"
|
||
@click.stop.prevent="toggleCheckbox(repo)"
|
||
>
|
||
★
|
||
</button>
|
||
<span
|
||
v-if="repo.isPrivate"
|
||
class="tile-lock"
|
||
aria-label="Private"
|
||
title="Private repo"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="22"
|
||
height="22"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"
|
||
/>
|
||
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||
<path d="M8 11v-4a4 4 0 1 1 8 0v4" />
|
||
</svg>
|
||
</span>
|
||
</router-link>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Others — A–Z -->
|
||
<section class="rl-section rl-others">
|
||
<div class="section-label mono">§ all repos</div>
|
||
<p
|
||
v-if="groupedOtherRepos.length === 0 && filterQuery"
|
||
class="rl-empty"
|
||
>
|
||
nothing matches “{{ filterQuery }}”.
|
||
</p>
|
||
<p v-else-if="otherRepos.length === 0" class="rl-empty">
|
||
no other repos in your account.
|
||
</p>
|
||
<div v-else class="rl-letter-stack">
|
||
<div
|
||
v-for="group in groupedOtherRepos"
|
||
:key="group.letter"
|
||
class="rl-letter-group"
|
||
>
|
||
<div class="rl-letter mono">{{ group.letter }}</div>
|
||
<ul class="rl-rows">
|
||
<li v-for="repo in group.repos" :key="repo.id" class="rl-row">
|
||
<button
|
||
type="button"
|
||
class="row-star"
|
||
:class="{ 'row-star--on': isStarred(repo) }"
|
||
:aria-pressed="isStarred(repo)"
|
||
:aria-label="isStarred(repo) ? 'Unstar' : 'Star'"
|
||
@click="toggleCheckbox(repo)"
|
||
>
|
||
{{ isStarred(repo) ? "★" : "☆" }}
|
||
</button>
|
||
<router-link
|
||
class="rl-row-name"
|
||
:to="{
|
||
name: 'FluxNoteView',
|
||
params: { user: username, repo: repo.name }
|
||
}"
|
||
>
|
||
{{ repo.name }}
|
||
</router-link>
|
||
<span
|
||
v-if="repo.isPrivate"
|
||
class="row-lock"
|
||
aria-label="Private"
|
||
title="Private repo"
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="22"
|
||
height="22"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"
|
||
/>
|
||
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||
<path d="M8 11v-4a4 4 0 1 1 8 0v4" />
|
||
</svg>
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<p v-if="canLoadMore" class="rl-loading-more mono">loading…</p>
|
||
</section>
|
||
</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>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* ── Tokens ────────────────────────────────────────────────── */
|
||
.repo-list-page {
|
||
--hw-surface: var(--color-base-100);
|
||
--hw-paper: var(--color-base-200);
|
||
--hw-paper-warm: var(--color-base-300);
|
||
--hw-ink: var(--color-base-content);
|
||
--hw-ink-soft: color-mix(
|
||
in oklch,
|
||
var(--color-base-content) 65%,
|
||
var(--color-base-200)
|
||
);
|
||
--hw-ink-faint: color-mix(
|
||
in oklch,
|
||
var(--color-base-content) 38%,
|
||
var(--color-base-200)
|
||
);
|
||
--hw-rule: color-mix(
|
||
in oklch,
|
||
var(--color-base-content) 12%,
|
||
var(--color-base-200)
|
||
);
|
||
|
||
--hw-pink: #e36598;
|
||
--hw-pink-deep: color-mix(in oklch, #e36598 75%, var(--color-base-content));
|
||
--hw-pink-wash: color-mix(in oklch, #e36598 12%, var(--color-base-200));
|
||
--hw-pink-wash-2: color-mix(in oklch, #e36598 22%, var(--color-base-200));
|
||
--hw-serif: "Libertinus Serif", "Iowan Old Style", "Palatino", serif;
|
||
--hw-mono: "Courier Prime", "IBM Plex Mono", ui-monospace, monospace;
|
||
|
||
font-family: var(--hw-serif);
|
||
background: var(--hw-paper);
|
||
color: var(--hw-ink);
|
||
font-size: 18px;
|
||
line-height: 1.55;
|
||
-webkit-font-smoothing: antialiased;
|
||
text-rendering: optimizeLegibility;
|
||
min-height: 100dvh;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.repo-list-page::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
radial-gradient(
|
||
circle at 20% 10%,
|
||
rgba(227, 101, 152, 0.04),
|
||
transparent 40%
|
||
),
|
||
radial-gradient(
|
||
circle at 90% 80%,
|
||
rgba(107, 142, 78, 0.035),
|
||
transparent 45%
|
||
);
|
||
}
|
||
|
||
main {
|
||
position: relative;
|
||
flex: 1;
|
||
}
|
||
|
||
/* ── Shared buttons ────────────────────────────────────────── */
|
||
.hw-btn {
|
||
font-family: var(--hw-serif);
|
||
font-size: 16px;
|
||
padding: 0.55rem 1rem;
|
||
border-radius: 2px;
|
||
border: 1px solid var(--hw-ink);
|
||
background: transparent;
|
||
color: var(--hw-ink);
|
||
cursor: pointer;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s,
|
||
transform 0.1s;
|
||
text-decoration: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
|
||
&:hover {
|
||
background: var(--hw-ink);
|
||
color: var(--hw-paper);
|
||
}
|
||
|
||
&:active {
|
||
transform: translateY(1px);
|
||
}
|
||
}
|
||
|
||
.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;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 1.25rem 2rem;
|
||
border-bottom: 1px solid var(--hw-rule);
|
||
background: var(--hw-paper);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
backdrop-filter: saturate(1.1) blur(6px);
|
||
}
|
||
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.55rem;
|
||
text-decoration: none;
|
||
color: var(--hw-ink);
|
||
}
|
||
|
||
.brand-mark {
|
||
display: block;
|
||
object-fit: contain;
|
||
view-transition-name: remanso-logo;
|
||
}
|
||
|
||
.brand-word {
|
||
font-size: 1.25rem;
|
||
letter-spacing: 0.02em;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.topnav-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1.25rem;
|
||
}
|
||
|
||
.navlink {
|
||
text-decoration: none;
|
||
color: var(--hw-ink-soft);
|
||
font-size: 0.95rem;
|
||
transition: color 0.15s;
|
||
|
||
&:hover {
|
||
color: var(--hw-pink-deep);
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.rl-hero-inner {
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.eyebrow {
|
||
font-size: 0.78rem;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: var(--hw-pink-deep);
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.li-h1 {
|
||
font-family: var(--hw-serif);
|
||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||
font-weight: 600;
|
||
margin: 0.25rem 0 0.75rem;
|
||
letter-spacing: -0.005em;
|
||
}
|
||
|
||
.li-sub {
|
||
margin: 0;
|
||
color: var(--hw-ink-soft);
|
||
font-size: 0.95rem;
|
||
display: flex;
|
||
gap: 0.6rem;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.li-dot {
|
||
color: var(--hw-ink-faint);
|
||
}
|
||
|
||
.li-sub-link {
|
||
color: var(--hw-ink-soft);
|
||
text-decoration: underline;
|
||
text-decoration-color: var(--hw-rule);
|
||
|
||
&:hover {
|
||
color: var(--hw-pink-deep);
|
||
}
|
||
}
|
||
|
||
/* ── Filter ────────────────────────────────────────────────── */
|
||
.rl-filter-wrap {
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
padding: 0.5rem 2rem 0;
|
||
}
|
||
|
||
.rl-filter {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.35rem 0.45rem 0.35rem 0.95rem;
|
||
background: var(--hw-surface);
|
||
border: 1px solid var(--hw-rule);
|
||
border-radius: 999px;
|
||
box-shadow:
|
||
0 1px 0 rgba(0, 0, 0, 0.02),
|
||
0 6px 18px -10px rgba(201, 74, 125, 0.15);
|
||
cursor: text;
|
||
transition:
|
||
border-color 0.15s,
|
||
box-shadow 0.15s;
|
||
min-width: min(360px, 100%);
|
||
|
||
&:focus-within {
|
||
border-color: var(--hw-pink);
|
||
box-shadow:
|
||
0 1px 0 rgba(0, 0, 0, 0.02),
|
||
0 10px 28px -10px rgba(201, 74, 125, 0.25);
|
||
}
|
||
}
|
||
|
||
.rl-filter-prefix {
|
||
color: var(--hw-ink-faint);
|
||
font-size: 0.78rem;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
user-select: none;
|
||
}
|
||
|
||
.rl-filter-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 0;
|
||
outline: 0;
|
||
background: transparent;
|
||
font-family: var(--hw-mono);
|
||
font-size: 0.95rem;
|
||
color: var(--hw-ink);
|
||
padding: 0.15rem 0;
|
||
|
||
&::placeholder {
|
||
color: var(--hw-ink-faint);
|
||
font-style: italic;
|
||
}
|
||
}
|
||
|
||
.rl-clear-btn {
|
||
flex-shrink: 0;
|
||
width: 1.6rem;
|
||
height: 1.6rem;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: var(--hw-pink-wash);
|
||
color: var(--hw-pink-deep);
|
||
font-size: 1.05rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition:
|
||
background 0.15s,
|
||
color 0.15s;
|
||
|
||
&:hover {
|
||
background: var(--hw-pink);
|
||
color: white;
|
||
}
|
||
}
|
||
|
||
/* ── Sections ──────────────────────────────────────────────── */
|
||
.rl-section {
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
padding: 0.5rem 2rem 2rem;
|
||
}
|
||
|
||
.section-label {
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.18em;
|
||
color: var(--hw-ink-faint);
|
||
text-transform: lowercase;
|
||
margin: 1.75rem 0 1rem;
|
||
}
|
||
|
||
.rl-empty {
|
||
margin: 0.5rem 0 1rem;
|
||
color: var(--hw-ink-faint);
|
||
font-style: italic;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
/* ── Favorites grid ────────────────────────────────────────── */
|
||
.favs-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 1rem;
|
||
}
|
||
|
||
.repo-tile {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
padding: 1rem 1.1rem;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||
transition:
|
||
transform 0.15s ease,
|
||
box-shadow 0.2s ease;
|
||
min-height: 110px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
text-decoration: none;
|
||
color: inherit;
|
||
|
||
&:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.15);
|
||
}
|
||
}
|
||
|
||
.tile-top {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
padding-right: 2rem;
|
||
}
|
||
|
||
.tile-owner {
|
||
font-size: 0.75rem;
|
||
opacity: 0.65;
|
||
}
|
||
|
||
.tile-name {
|
||
font-size: 1.2rem;
|
||
font-weight: 600;
|
||
line-height: 1.15;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.tile-star {
|
||
position: absolute;
|
||
top: 0.55rem;
|
||
right: 0.6rem;
|
||
width: 1.9rem;
|
||
height: 1.9rem;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
color: var(--hw-pink-deep);
|
||
font-size: 1.05rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
backdrop-filter: blur(2px);
|
||
transition:
|
||
background 0.15s,
|
||
transform 0.1s;
|
||
|
||
&:hover {
|
||
background: white;
|
||
transform: scale(1.08);
|
||
}
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
|
||
.tile-lock {
|
||
position: absolute;
|
||
bottom: 0.55rem;
|
||
right: 0.7rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0.75;
|
||
user-select: none;
|
||
}
|
||
|
||
/* ── Skeleton ──────────────────────────────────────────────── */
|
||
.repo-tile--skel {
|
||
background: var(--hw-surface);
|
||
border-color: var(--hw-rule);
|
||
pointer-events: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
padding: 1rem 1.1rem;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.skel {
|
||
display: block;
|
||
border-radius: 3px;
|
||
background: linear-gradient(
|
||
90deg,
|
||
var(--hw-pink-wash) 0%,
|
||
var(--hw-pink-wash-2) 50%,
|
||
var(--hw-pink-wash) 100%
|
||
);
|
||
background-size: 200% 100%;
|
||
animation: skel-shimmer 1.4s ease-in-out infinite;
|
||
}
|
||
|
||
.skel-handle {
|
||
height: 0.6rem;
|
||
width: 45%;
|
||
}
|
||
|
||
.skel-title {
|
||
height: 1.1rem;
|
||
width: 75%;
|
||
}
|
||
|
||
@keyframes skel-shimmer {
|
||
0% {
|
||
background-position: 200% 0;
|
||
}
|
||
100% {
|
||
background-position: -200% 0;
|
||
}
|
||
}
|
||
|
||
/* ── Credential-error card ─────────────────────────────────── */
|
||
.rl-card {
|
||
max-width: 560px;
|
||
padding: 2rem 2rem 1.75rem;
|
||
background: var(--hw-surface);
|
||
border: 1px solid var(--hw-rule);
|
||
border-radius: 6px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.rl-card-title {
|
||
font-family: var(--hw-serif);
|
||
font-size: 1.6rem;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.rl-card-body {
|
||
margin: 0 0 0.5rem;
|
||
color: var(--hw-ink-soft);
|
||
font-size: 1rem;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
/* ── Others (A–Z) ──────────────────────────────────────────── */
|
||
.rl-letter-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1.25rem;
|
||
}
|
||
|
||
.rl-letter-group {
|
||
display: grid;
|
||
grid-template-columns: 2.5rem 1fr;
|
||
gap: 0.5rem 1rem;
|
||
align-items: start;
|
||
}
|
||
|
||
.rl-letter {
|
||
font-size: 1.4rem;
|
||
color: var(--hw-pink-deep);
|
||
letter-spacing: 0.05em;
|
||
font-weight: 600;
|
||
padding-top: 0.45rem;
|
||
text-align: right;
|
||
border-right: 1px dashed var(--hw-rule);
|
||
padding-right: 0.75rem;
|
||
position: sticky;
|
||
top: calc(72px + 0.5rem);
|
||
}
|
||
|
||
.rl-rows {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.rl-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
padding: 0.55rem 0.75rem;
|
||
border-bottom: 1px dashed var(--hw-rule);
|
||
border-radius: 4px;
|
||
transition: background 0.15s ease;
|
||
|
||
&:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
|
||
&:hover {
|
||
background: var(--hw-pink-wash);
|
||
}
|
||
}
|
||
|
||
.row-star {
|
||
flex-shrink: 0;
|
||
width: 1.8rem;
|
||
height: 1.8rem;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--hw-ink-faint);
|
||
font-size: 1.05rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition:
|
||
color 0.15s,
|
||
background 0.15s,
|
||
transform 0.1s;
|
||
|
||
&:hover {
|
||
color: var(--hw-pink-deep);
|
||
background: var(--hw-pink-wash-2);
|
||
}
|
||
|
||
&:active {
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
&.row-star--on {
|
||
color: var(--hw-pink-deep);
|
||
}
|
||
}
|
||
|
||
.rl-row-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
color: var(--hw-ink);
|
||
text-decoration: none;
|
||
font-size: 1rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
|
||
&:hover {
|
||
color: var(--hw-pink-deep);
|
||
}
|
||
}
|
||
|
||
.row-lock {
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--hw-ink-faint);
|
||
user-select: none;
|
||
}
|
||
|
||
.rl-loading-more {
|
||
margin: 1.5rem 0 0;
|
||
text-align: center;
|
||
font-size: 0.78rem;
|
||
letter-spacing: 0.18em;
|
||
color: var(--hw-ink-faint);
|
||
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 {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.topnav {
|
||
padding: 1rem 1.25rem;
|
||
}
|
||
|
||
.navlink {
|
||
display: none;
|
||
}
|
||
|
||
.rl-hero,
|
||
.rl-filter-wrap,
|
||
.rl-section {
|
||
padding-left: 1.25rem;
|
||
padding-right: 1.25rem;
|
||
}
|
||
|
||
.rl-letter-group {
|
||
grid-template-columns: 2rem 1fr;
|
||
gap: 0.4rem 0.75rem;
|
||
}
|
||
|
||
.rl-letter {
|
||
font-size: 1.15rem;
|
||
padding-right: 0.4rem;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 520px) {
|
||
.favs-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|