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..6e30c28 100644 --- a/src/components/Aside/Aside.tsx +++ b/src/components/Aside/Aside.tsx @@ -44,11 +44,11 @@ export function Aside({ onCut, onCopy, onPaste, - onMoveFolder, - onMoveSnippet, onSelectFolder, onSignIn, onSignOut, + signingIn, + signingOut, onOpenTrash, onRestoreAll, onEmptyTrash, @@ -229,6 +229,8 @@ export function Aside({ onSetOpen(false)} diff --git a/src/components/Aside/AsideHeader.tsx b/src/components/Aside/AsideHeader.tsx index b963d3c..6a2b157 100644 --- a/src/components/Aside/AsideHeader.tsx +++ b/src/components/Aside/AsideHeader.tsx @@ -1,19 +1,25 @@ "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"; 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; @@ -24,9 +30,11 @@ export function AsideHeader({ {user ? (
- {user.user_metadata.full_name
@@ -35,24 +43,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..7183f66 100644 --- a/src/components/Aside/types.ts +++ b/src/components/Aside/types.ts @@ -27,11 +27,13 @@ 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; + /** 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..8600dda 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 ( setClipboard({ ...entry, type: "copy" })} onPaste={mutations.handlePaste} - onMoveFolder={mutations.handleMoveFolder} - onMoveSnippet={mutations.handleMoveSnippet} onSignIn={auth.handleGitHubSignIn} onSignOut={auth.handleSignOut} + signingIn={auth.signingIn} + signingOut={auth.signingOut} onSelectFolder={(folderId) => 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 ( + + + + + ); +}