Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 7 additions & 17 deletions src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,14 +59,6 @@ function IconCode() {
);
}

function IconArrowRight() {
return (
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
);
}

function IconGlobe() {
return (
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
Expand Down Expand Up @@ -119,13 +112,12 @@ export default async function LandingPage({
{altLocale.toUpperCase()}
</LocaleSwitchLink>

<Link
<AppCtaLink
href={appHref}
className="group hidden items-center gap-2 rounded-full bg-white px-4 py-1.5 text-sm font-medium text-[#0a0a0a] transition-all hover:bg-white/90 active:scale-[0.97] md:flex"
>
{l.nav.openApp}
<IconArrowRight />
</Link>
</AppCtaLink>
</div>
</LandingHeader>

Expand Down Expand Up @@ -155,13 +147,12 @@ export default async function LandingPage({
</p>

<div className="landing-fade-in landing-delay-2 mt-10 flex flex-col items-center gap-3">
<Link
<AppCtaLink
href={appHref}
className="group inline-flex items-center gap-2.5 rounded-full bg-white px-7 py-3 text-sm font-semibold text-[#0a0a0a] shadow-[0_0_0_1px_rgba(255,255,255,0.1),0_2px_20px_rgba(255,255,255,0.1)] transition-all hover:shadow-[0_0_0_1px_rgba(255,255,255,0.15),0_2px_30px_rgba(255,255,255,0.15)] active:scale-[0.97]"
>
{l.hero.cta}
<IconArrowRight />
</Link>
</AppCtaLink>
<span className="text-xs text-muted/70">{l.hero.ctaHint}</span>
</div>

Expand Down Expand Up @@ -308,13 +299,12 @@ export default async function LandingPage({
{l.cta.title}
</h2>
<p className="mt-5 text-muted leading-relaxed">{l.cta.subtitle}</p>
<Link
<AppCtaLink
href={appHref}
className="mt-8 inline-flex items-center gap-2.5 rounded-full bg-white px-8 py-3.5 text-sm font-semibold text-[#0a0a0a] shadow-[0_0_0_1px_rgba(255,255,255,0.1),0_2px_20px_rgba(255,255,255,0.1)] transition-all hover:shadow-[0_0_0_1px_rgba(255,255,255,0.15),0_2px_30px_rgba(255,255,255,0.15)] active:scale-[0.97]"
>
{l.cta.button}
<IconArrowRight />
</Link>
</AppCtaLink>
</div>
</section>

Expand Down
45 changes: 45 additions & 0 deletions src/components/AppCtaLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
);
}

/**
* Trailing icon that flips to a spinner while the parent <Link> is navigating.
* `useLinkStatus` only reports pending state from inside a Link descendant, hence
* the split component.
*/
function CtaIcon() {
const { pending } = useLinkStatus();
return pending ? <Spinner size={16} /> : <ArrowRight />;
}

/**
* 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 (
<Link href={href} className={className}>
{children}
<CtaIcon />
</Link>
);
}
6 changes: 4 additions & 2 deletions src/components/Aside/Aside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ export function Aside({
onCut,
onCopy,
onPaste,
onMoveFolder,
onMoveSnippet,
onSelectFolder,
onSignIn,
onSignOut,
signingIn,
signingOut,
onOpenTrash,
onRestoreAll,
onEmptyTrash,
Expand Down Expand Up @@ -229,6 +229,8 @@ export function Aside({
<AsideHeader
user={user}
copy={copy}
signingIn={signingIn}
signingOut={signingOut}
onSignIn={onSignIn}
onSignOut={onSignOut}
onCollapse={() => onSetOpen(false)}
Expand Down
32 changes: 24 additions & 8 deletions src/components/Aside/AsideHeader.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,9 +30,11 @@ export function AsideHeader({
{user ? (
<div className="flex min-w-0 flex-1 items-center gap-2.5 p-1">
<div className="relative h-6 w-6 shrink-0 overflow-hidden rounded-full ring-1 ring-white/10">
<img
<Image
src={user.user_metadata.avatar_url}
alt={user.user_metadata.full_name || user.email || "Avatar"}
width={24}
height={24}
className="h-full w-full object-cover"
/>
</div>
Expand All @@ -35,24 +43,32 @@ export function AsideHeader({
{user.user_metadata.full_name || user.email?.split("@")[0]}
</span>
</div>
<Tooltip content={copy.auth.signOut} placement="bottom">
<Tooltip content={signingOut ? copy.auth.signingOut : copy.auth.signOut} placement="bottom">
<button
onClick={onSignOut}
className="shrink-0 rounded p-1 text-white/25 transition-colors hover:bg-white/10 hover:text-white/60"
aria-label={copy.auth.signOut}
disabled={signingOut}
className="shrink-0 rounded p-1 text-white/25 transition-colors hover:bg-white/10 hover:text-white/60 disabled:cursor-default disabled:hover:bg-transparent"
aria-label={signingOut ? copy.auth.signingOut : copy.auth.signOut}
aria-busy={signingOut}
>
<LogOut size={12} />
{signingOut ? <Spinner size={12} /> : <LogOut size={12} />}
</button>
</Tooltip>
</div>
) : (
<button
onClick={onSignIn}
className="group flex min-w-0 flex-1 items-center gap-2.5 py-1 pl-1 pr-2 text-left transition-colors hover:text-foreground"
disabled={signingIn}
className="group flex min-w-0 flex-1 items-center gap-2.5 py-1 pl-1 pr-2 text-left transition-colors hover:text-foreground disabled:cursor-default"
aria-busy={signingIn}
>
<LogIn size={15} className="shrink-0 text-white/60 group-hover:text-white" />
{signingIn ? (
<Spinner size={15} className="shrink-0 text-white/80" />
) : (
<LogIn size={15} className="shrink-0 text-white/60 group-hover:text-white" />
)}
<span className="truncate text-[12px] font-medium text-foreground/80 group-hover:text-foreground">
{copy.auth.signIn}
{signingIn ? copy.auth.signingIn : copy.auth.signIn}
</span>
</button>
)}
Expand Down
6 changes: 4 additions & 2 deletions src/components/Aside/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ export interface AsideProps {
onCut: (entry: ClipboardEntry) => void;
onCopy: (entry: ClipboardEntry) => void;
onPaste: (targetFolderId: string | null) => Promise<void>;
onMoveFolder: (id: string, newParentId: string | null) => Promise<void>;
onMoveSnippet: (id: string, newFolderId: string | null) => Promise<void>;
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). */
Expand Down
16 changes: 14 additions & 2 deletions src/components/KlipCodeApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="flex h-screen flex-col items-center justify-center gap-3 text-white/45">
<Spinner size={22} label={copy.workspace.loading} />
<p className="text-sm">{copy.workspace.loading}</p>
</div>
);
}

return (
<DragProvider
folders={folders}
Expand Down Expand Up @@ -296,10 +308,10 @@ 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}
signingOut={auth.signingOut}
onSelectFolder={(folderId) => navigate(`${base}?folder=${folderId}`)}
onOpenTrash={() => navigate(`${base}?folder=${TRASH_ROOT_ID}`)}
onRestoreAll={() => void mutations.handleRestoreAll()}
Expand Down
42 changes: 31 additions & 11 deletions src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export function useAuth({ copy, refreshWorkspace, onReconciled }: UseAuthOptions

const [user, setUser] = useState<User | null>(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<string>(
supabaseConfigured ? copy.auth.localMode : copy.auth.notConfigured
);
Expand Down Expand Up @@ -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 {
Expand All @@ -129,6 +147,8 @@ export function useAuth({ copy, refreshWorkspace, onReconciled }: UseAuthOptions
setAccountMessage,
supabase,
supabaseConfigured,
signingIn,
signingOut,
handleGitHubSignIn,
handleSignOut,
};
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading