From a15c7c27e487ef5729b055e042f39ee7fc6ebdae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 10:30:35 +0000 Subject: [PATCH 1/2] feat(ui): add loading feedback for slow actions across the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface progress for the actions that round-trip to the network or storage so they never look frozen: - New `Spinner` UI primitive (currentColor SVG ring) used everywhere below. - Auth: `useAuth` now tracks `signingIn` / `signingOut`. The sidebar sign-in button shows a spinner + "Signing in…" through the off-page GitHub redirect (cleared only on error so it can be retried), and sign-out shows a spinner while the session and local data are cleared. Both buttons disable while busy. - First load: KlipCodeApp shows a centered spinner while the initial IndexedDB workspace read is pending, instead of a blank shell on a cold start. - Landing: the "Open app" / hero / final CTAs use Next's `useLinkStatus` to flip their arrow to a spinner the moment they're clicked, since navigating into the app shell is a real round-trip on first visit. Adds `auth.signingIn` / `auth.signingOut` to both locale dictionaries. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XFQW9aw6XqpwkQUScq3ogU --- src/app/[locale]/page.tsx | 24 +++++---------- src/components/AppCtaLink.tsx | 45 ++++++++++++++++++++++++++++ src/components/Aside/Aside.tsx | 4 +++ src/components/Aside/AsideHeader.tsx | 27 ++++++++++++----- src/components/Aside/types.ts | 4 +++ src/components/KlipCodeApp.tsx | 14 +++++++++ src/hooks/useAuth.ts | 42 +++++++++++++++++++------- src/i18n/en.ts | 2 ++ src/i18n/es.ts | 2 ++ src/ui/Spinner.tsx | 41 +++++++++++++++++++++++++ 10 files changed, 170 insertions(+), 35 deletions(-) create mode 100644 src/components/AppCtaLink.tsx create mode 100644 src/ui/Spinner.tsx diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index dbaf693..975aaaa 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -4,6 +4,7 @@ import { getDictionary } from "@/i18n"; import { HeroPerspective } from "@/components/HeroPerspective"; import { LandingHeader } from "@/components/LandingHeader"; import { LocaleSwitchLink } from "@/components/LocaleSwitchLink"; +import { AppCtaLink } from "@/components/AppCtaLink"; import { isLocale, localeHref, type Locale } from "@/lib/locale"; import { Logo } from "@/ui/Logo"; import { GitHubIcon } from "@/components/Aside/GitHubIcon"; @@ -58,14 +59,6 @@ function IconCode() { ); } -function IconArrowRight() { - return ( - - - - ); -} - function IconGlobe() { return ( @@ -119,13 +112,12 @@ export default async function LandingPage({ {altLocale.toUpperCase()} - {l.nav.openApp} - - + @@ -155,13 +147,12 @@ export default async function LandingPage({

- {l.hero.cta} - - + {l.hero.ctaHint}
@@ -308,13 +299,12 @@ export default async function LandingPage({ {l.cta.title}

{l.cta.subtitle}

- {l.cta.button} - - + diff --git a/src/components/AppCtaLink.tsx b/src/components/AppCtaLink.tsx new file mode 100644 index 0000000..38c56f4 --- /dev/null +++ b/src/components/AppCtaLink.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link, { useLinkStatus } from "next/link"; +import type { ReactNode } from "react"; +import { Spinner } from "@/ui/Spinner"; + +function ArrowRight() { + return ( + + + + ); +} + +/** + * Trailing icon that flips to a spinner while the parent is navigating. + * `useLinkStatus` only reports pending state from inside a Link descendant, hence + * the split component. + */ +function CtaIcon() { + const { pending } = useLinkStatus(); + return pending ? : ; +} + +/** + * Landing-page call-to-action that links into the app shell. Navigating to `/app` + * loads a fresh route (a real round-trip on first visit), so the button shows a + * spinner the moment it's clicked instead of looking inert until the page swaps. + */ +export function AppCtaLink({ + href, + className, + children, +}: { + href: string; + className?: string; + children: ReactNode; +}) { + return ( + + {children} + + + ); +} diff --git a/src/components/Aside/Aside.tsx b/src/components/Aside/Aside.tsx index 042eb90..bfda0e5 100644 --- a/src/components/Aside/Aside.tsx +++ b/src/components/Aside/Aside.tsx @@ -49,6 +49,8 @@ export function Aside({ onSelectFolder, onSignIn, onSignOut, + signingIn, + signingOut, onOpenTrash, onRestoreAll, onEmptyTrash, @@ -229,6 +231,8 @@ export function Aside({ onSetOpen(false)} diff --git a/src/components/Aside/AsideHeader.tsx b/src/components/Aside/AsideHeader.tsx index b963d3c..8951aa8 100644 --- a/src/components/Aside/AsideHeader.tsx +++ b/src/components/Aside/AsideHeader.tsx @@ -4,16 +4,21 @@ import { ChevronsLeft, LogIn, LogOut } from "lucide-react"; import type { Dictionary } from "@/i18n"; import type { User } from "@supabase/supabase-js"; import { Tooltip } from "@/ui/Tooltip"; +import { Spinner } from "@/ui/Spinner"; export function AsideHeader({ user, copy, + signingIn, + signingOut, onSignIn, onSignOut, onCollapse, }: { user: User | null; copy: Dictionary; + signingIn: boolean; + signingOut: boolean; onSignIn: () => void; onSignOut: () => void; onCollapse: () => void; @@ -35,24 +40,32 @@ export function AsideHeader({ {user.user_metadata.full_name || user.email?.split("@")[0]} - + ) : ( )} diff --git a/src/components/Aside/types.ts b/src/components/Aside/types.ts index 1691264..c9ad749 100644 --- a/src/components/Aside/types.ts +++ b/src/components/Aside/types.ts @@ -32,6 +32,10 @@ export interface AsideProps { onSelectFolder?: (folderId: string) => void; onSignIn: () => void; onSignOut: () => void; + /** Sign-in is redirecting to GitHub. */ + signingIn: boolean; + /** Sign-out is clearing the session and local data. */ + signingOut: boolean; /** Open the trash view. */ onOpenTrash: () => void; /** Restore every trashed record (no confirmation — non-destructive). */ diff --git a/src/components/KlipCodeApp.tsx b/src/components/KlipCodeApp.tsx index 99e4908..2629926 100644 --- a/src/components/KlipCodeApp.tsx +++ b/src/components/KlipCodeApp.tsx @@ -12,6 +12,7 @@ import { getDictionary } from "@/i18n"; import { localeHref } from "@/lib/locale"; import { SPACE_ROOT_ID, TRASH_ROOT_ID } from "@/lib/navigation"; import { Tooltip } from "@/ui/Tooltip"; +import { Spinner } from "@/ui/Spinner"; import { useResponsiveSidebar } from "@/hooks/useResponsiveSidebar"; import { useAuth } from "@/hooks/useAuth"; @@ -255,6 +256,17 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { ); } + // First read of the local workspace from IndexedDB. Show a spinner instead of a + // blank shell so a cold start (or a large workspace) doesn't look frozen. + if (workspaceQuery.isPending) { + return ( +
+ +

{copy.workspace.loading}

+
+ ); + } + return ( navigate(`${base}?folder=${folderId}`)} onOpenTrash={() => navigate(`${base}?folder=${TRASH_ROOT_ID}`)} onRestoreAll={() => void mutations.handleRestoreAll()} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index ce5cdf1..183534c 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -17,6 +17,10 @@ export function useAuth({ copy, refreshWorkspace, onReconciled }: UseAuthOptions const [user, setUser] = useState(null); const [authReady, setAuthReady] = useState(false); + // Pending flags for the auth actions, which round-trip to GitHub/Supabase and + // can leave the UI looking frozen otherwise (sign-in redirects off-page). + const [signingIn, setSigningIn] = useState(false); + const [signingOut, setSigningOut] = useState(false); const [accountMessage, setAccountMessage] = useState( supabaseConfigured ? copy.auth.localMode : copy.auth.notConfigured ); @@ -100,26 +104,40 @@ export function useAuth({ copy, refreshWorkspace, onReconciled }: UseAuthOptions ]); async function handleGitHubSignIn() { - if (!supabase) return; + if (!supabase || signingIn) return; + + // Stays true through the off-page redirect to GitHub; only an error (which + // keeps us on the page) clears it so the button can be retried. + setSigningIn(true); + setAccountMessage(copy.auth.signingIn); const { error } = await supabase.auth.signInWithOAuth({ provider: "github", options: { redirectTo: window.location.href }, }); - if (error) setAccountMessage(copy.auth.syncFailed); + if (error) { + setSigningIn(false); + setAccountMessage(copy.auth.syncFailed); + } } async function handleSignOut() { - if (!supabase) return; - const signedOutUserId = user?.id ?? null; - await supabase.auth.signOut(); - // Wipe this account's local data so it isn't readable on a shared machine. - // Synced data comes back from the cloud on the next sign-in. - if (signedOutUserId) await clearOwnedData(signedOutUserId); - setUser(null); - setAccountMessage(supabaseConfigured ? copy.auth.localMode : copy.auth.notConfigured); - refreshRef.current(); + if (!supabase || signingOut) return; + setSigningOut(true); + setAccountMessage(copy.auth.signingOut); + try { + const signedOutUserId = user?.id ?? null; + await supabase.auth.signOut(); + // Wipe this account's local data so it isn't readable on a shared machine. + // Synced data comes back from the cloud on the next sign-in. + if (signedOutUserId) await clearOwnedData(signedOutUserId); + setUser(null); + setAccountMessage(supabaseConfigured ? copy.auth.localMode : copy.auth.notConfigured); + refreshRef.current(); + } finally { + setSigningOut(false); + } } return { @@ -129,6 +147,8 @@ export function useAuth({ copy, refreshWorkspace, onReconciled }: UseAuthOptions setAccountMessage, supabase, supabaseConfigured, + signingIn, + signingOut, handleGitHubSignIn, handleSignOut, }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 7e20eec..bdf9853 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -58,6 +58,8 @@ export const en = { cloudSyncRunning: "Syncing changes to the cloud.", syncFailed: "Could not sync with the cloud.", signedInAs: "User", + signingIn: "Signing in…", + signingOut: "Signing out…", }, forms: { folderTitle: "New folder", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index a9dfc43..9c1f553 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -59,6 +59,8 @@ export const es = { cloudSyncRunning: "Sincronizando cambios con la nube.", syncFailed: "No se pudo sincronizar con la nube.", signedInAs: "Usuario", + signingIn: "Iniciando sesión…", + signingOut: "Cerrando sesión…", }, forms: { folderTitle: "Nueva carpeta", diff --git a/src/ui/Spinner.tsx b/src/ui/Spinner.tsx new file mode 100644 index 0000000..9df6ca3 --- /dev/null +++ b/src/ui/Spinner.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties } from "react"; + +interface SpinnerProps { + /** Diameter in px. */ + size?: number; + /** Stroke thickness in px. */ + strokeWidth?: number; + className?: string; + /** Accessible label; omit on purely decorative spinners next to visible text. */ + label?: string; +} + +/** + * Minimal, theme-agnostic loading spinner. Inherits `currentColor`, so it adopts + * the text color of whatever it sits in (button, toast, empty state). Kept as an + * SVG ring rather than a border trick so it stays crisp at any size and respects + * the brand's thin-stroke aesthetic. + */ +export function Spinner({ size = 14, strokeWidth = 2, className = "", label }: SpinnerProps) { + const style: CSSProperties = { width: size, height: size }; + + return ( + + + + + ); +} From 2e3ca2589609f709fe428f451b29c8a2b53bbd57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20Mart=C3=ADnez?= Date: Thu, 25 Jun 2026 13:56:06 +0200 Subject: [PATCH 2/2] fix(lint): drop dead onMove props from Aside and use next/image for avatar --- src/components/Aside/Aside.tsx | 2 -- src/components/Aside/AsideHeader.tsx | 5 ++++- src/components/Aside/types.ts | 2 -- src/components/KlipCodeApp.tsx | 2 -- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/Aside/Aside.tsx b/src/components/Aside/Aside.tsx index bfda0e5..6e30c28 100644 --- a/src/components/Aside/Aside.tsx +++ b/src/components/Aside/Aside.tsx @@ -44,8 +44,6 @@ export function Aside({ onCut, onCopy, onPaste, - onMoveFolder, - onMoveSnippet, onSelectFolder, onSignIn, onSignOut, diff --git a/src/components/Aside/AsideHeader.tsx b/src/components/Aside/AsideHeader.tsx index 8951aa8..6a2b157 100644 --- a/src/components/Aside/AsideHeader.tsx +++ b/src/components/Aside/AsideHeader.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import { ChevronsLeft, LogIn, LogOut } from "lucide-react"; import type { Dictionary } from "@/i18n"; import type { User } from "@supabase/supabase-js"; @@ -29,9 +30,11 @@ export function AsideHeader({ {user ? (
- {user.user_metadata.full_name
diff --git a/src/components/Aside/types.ts b/src/components/Aside/types.ts index c9ad749..7183f66 100644 --- a/src/components/Aside/types.ts +++ b/src/components/Aside/types.ts @@ -27,8 +27,6 @@ export interface AsideProps { onCut: (entry: ClipboardEntry) => void; onCopy: (entry: ClipboardEntry) => void; onPaste: (targetFolderId: string | null) => Promise; - onMoveFolder: (id: string, newParentId: string | null) => Promise; - onMoveSnippet: (id: string, newFolderId: string | null) => Promise; onSelectFolder?: (folderId: string) => void; onSignIn: () => void; onSignOut: () => void; diff --git a/src/components/KlipCodeApp.tsx b/src/components/KlipCodeApp.tsx index 2629926..8600dda 100644 --- a/src/components/KlipCodeApp.tsx +++ b/src/components/KlipCodeApp.tsx @@ -308,8 +308,6 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { onCut={setClipboard} onCopy={(entry) => setClipboard({ ...entry, type: "copy" })} onPaste={mutations.handlePaste} - onMoveFolder={mutations.handleMoveFolder} - onMoveSnippet={mutations.handleMoveSnippet} onSignIn={auth.handleGitHubSignIn} onSignOut={auth.handleSignOut} signingIn={auth.signingIn}