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 (
-
{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 ? (
-
@@ -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 (
+
+ );
+}