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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { ArrowLeftIcon, ArrowUpRightIcon, SquareTerminal } from "lucide-react";
import {
Sidebar,
SidebarContent,
Expand All @@ -23,9 +23,12 @@ type SettingsCategory = { href: string; label: string };

export default function SettingsShell({
orgName,
consoleHref,
children,
}: {
orgName: string;
/** The organization's Console (AI Gateway) page. */
consoleHref: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
Expand Down Expand Up @@ -84,6 +87,22 @@ export default function SettingsShell({
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* TODO(org-home-gate): Console entry added with the `/<org>` gate; revisit with the product catalogue / links cleanup. */}
<SidebarGroup className="mt-auto">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Console">
<a href={consoleHref}>
<SquareTerminal />
<span>Console</span>
<ArrowUpRightIcon className="ms-auto opacity-50" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>{children}</SidebarInset>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SettingsShell from "./_shell";
import { ConsoleLinks } from "@/lib/platform/console-links";

type Params = { organization_name: string };

Expand All @@ -12,5 +13,12 @@ export default async function SettingsLayout({
const { organization_name } = await params;
// Billing handoffs and the profile view enforce auth and the caller's RLS
// organization lookup. Keep their resource-specific sign-in continuation.
return <SettingsShell orgName={organization_name}>{children}</SettingsShell>;
return (
<SettingsShell
orgName={organization_name}
consoleHref={ConsoleLinks.aiGateway(organization_name)}
>
{children}
</SettingsShell>
);
}
12 changes: 12 additions & 0 deletions editor/app/(workspace)/[org]/[proj]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { WorkspaceShell } from "../_components/workspace-shell";

export default async function ProjectLayout({
params,
children,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ org: string }>;
}>) {
const { org } = await params;
return <WorkspaceShell org={org}>{children}</WorkspaceShell>;
}
124 changes: 124 additions & 0 deletions editor/app/(workspace)/[org]/_components/home-gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import React from "react";
import { useRouter } from "next/navigation";
import {
ArrowRightIcon,
ArrowUpRightIcon,
FolderKanban,
SquareTerminal,
} from "lucide-react";
import { useWorkspace } from "@/scaffolds/workspace";
import { OrgHome } from "./org-home";

// TODO(org-home-gate): TEMPORARY. Stopgap so Console users (AI Gateway / FX)
// landing on grida.co aren't pushed into the legacy "Work" (projects / forms /
// database) onboarding. Remove it when the product catalogue and its links
// (the `/<org>` home, `/dashboard`, sitemap product items, Console handoffs) are
// reorganized for real. `rg "TODO\(org-home-gate\)"` lists every touchpoint;
// removal checklist:
// - `[org]/page.tsx`: render `WorkHome` unconditionally; delete this file and
// `org-home.ts` (the `grida-org-home` cookie, `Path=/<org>`, needs no
// cleanup — it is simply ignored).
// - Decide whether to keep the sidebar "Console" links (workspace sidebar and
// org settings sidebar) and the `WorkspaceShell` split — both outlive the
// gate and are fine to keep.
// - Deprecate the manual TC (see test/workspace-org-home-gate.md).
/**
* First stop at `/<org>`: pick the Console (AI Gateway / FX, keys, credits) or
* "Work" (projects and documents). Choosing Work is remembered for this org
* in this browser; the Console stays one click away in the sidebar.
*/
export function HomeGate({ consoleHref }: { consoleHref: string }) {
const router = useRouter();
const { organization } = useWorkspace();

const chooseWork = () => {
document.cookie = OrgHome.workCookie(organization.name);
router.refresh();
};

return (
<main
data-testid="org-home-gate"
className="w-full h-full overflow-y-auto flex items-center justify-center p-6"
>
<div className="w-full max-w-4xl">
<div className="grid gap-4 sm:grid-cols-2">
<GateCard
as="a"
href={consoleHref}
icon={<SquareTerminal className="size-6" />}
title="Console"
description="For AI Gateway and FX — API keys, credits, usage and billing."
hint={new URL(consoleHref).host}
arrow={<ArrowUpRightIcon className="size-6" />}
/>
<GateCard
as="button"
onClick={chooseWork}
icon={<FolderKanban className="size-6" />}
title="Work"
description="Projects, forms, databases and documents in this organization."
hint="Remembered on this device"
arrow={<ArrowRightIcon className="size-6" />}
/>
</div>
</div>
</main>
);
}

type GateCardProps = {
icon: React.ReactNode;
title: string;
description: string;
hint: string;
arrow: React.ReactNode;
} & (
| { as: "a"; href: string; onClick?: never }
| { as: "button"; onClick: () => void; href?: never }
);

function GateCard({
as,
href,
onClick,
icon,
title,
description,
hint,
arrow,
}: GateCardProps) {
const className =
"group flex min-h-80 flex-col rounded-2xl border bg-card p-8 text-left shadow-xs transition-colors hover:border-foreground/30 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";

const body = (
<>
<div className="flex size-12 items-center justify-center rounded-xl border bg-background">
{icon}
</div>
<div className="mt-16 flex items-center justify-between">
<span className="text-3xl font-semibold">{title}</span>
<span className="text-muted-foreground transition-transform group-hover:translate-x-0.5">
{arrow}
</span>
</div>
<p className="mt-2 text-base text-muted-foreground">{description}</p>
<p className="mt-auto pt-4 text-xs text-muted-foreground/70">{hint}</p>
</>
);

if (as === "a") {
return (
<a href={href} className={className}>
{body}
</a>
);
}
return (
<button type="button" onClick={onClick} className={className}>
{body}
</button>
);
}
24 changes: 24 additions & 0 deletions editor/app/(workspace)/[org]/_components/org-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// TODO(org-home-gate): temporary — see `home-gate.tsx` for the removal plan.
/**
* Which surface `/<org>` opens: the Console gate, or the "Work" home.
*
* Only "work" is remembered, in a browser cookie scoped to the org's path
* (`Path=/<org>`), so the choice is per organization and per browser. Console
* is never remembered — it leaves grida.co, and remembering it would turn
* `/<org>` into a redirect away from the organization's own home.
*/
export namespace OrgHome {
export const COOKIE = "grida-org-home";
export const WORK = "work";

const ONE_YEAR = 60 * 60 * 24 * 365;

export function isWork(value: string | undefined): boolean {
return value === WORK;
}

/** `document.cookie` assignment that remembers "Work" for `org`. */
export function workCookie(org: string): string {
return `${COOKIE}=${WORK}; Path=/${encodeURIComponent(org)}; Max-Age=${ONE_YEAR}; SameSite=Lax`;
}
}
142 changes: 142 additions & 0 deletions editor/app/(workspace)/[org]/_components/work-home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"use client";

import React from "react";
import Link from "next/link";
import {
ChevronDownIcon,
OpenInNewWindowIcon,
PlusIcon,
ViewGridIcon,
ViewHorizontalIcon,
} from "@radix-ui/react-icons";
import { CreateNewDocumentButton } from "@/scaffolds/workspace/create-new-document-button";
import { ProjectStats } from "@/scaffolds/analytics/stats";
import { PoweredByGridaFooter } from "@/grida-forms-hosted/e/powered-by-brand-footer";
import { FolderPlus } from "lucide-react";
import { CreateNewProjectDialog } from "@/scaffolds/workspace/new-project-dialog";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@app/ui/components/empty";
import { Button } from "@app/ui/components/button";
import { useWorkspace } from "@/scaffolds/workspace";
import { Skeleton } from "@app/ui/components/skeleton";
import { DocumentsGrid } from "./documents-grid";

/**
* The organization "Work" home — projects and their documents.
*/
export function WorkHome({ layout }: { layout: "grid" | "list" }) {
const { loading, organization, projects, documents, refresh } =
useWorkspace();

return (
<main className="w-full h-full overflow-y-scroll">
<div className="container mx-auto">
<header className="py-10">
<div>
<span className="text-2xl font-black">Home</span>
</div>
</header>
{loading ? (
<></>
) : (
<>
<section className="py-10">
<ProjectStats project_ids={projects.map((p) => p.id)} />
</section>
</>
)}
<section className="w-full flex justify-end gap-2 mt-10">
<Link href="?layout=grid" replace>
<ViewGridIcon />
</Link>
<Link href="?layout=list" replace>
<ViewHorizontalIcon />
</Link>
</section>
<hr className="mb-10 mt-5" />
{loading ? (
<ProjectsLoading />
) : (
<>
{projects.length === 0 && (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderPlus />
</EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>
Create your first project to get started.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<CreateNewProjectDialog org={organization.name}>
<Button variant="outline">
<PlusIcon />
Create your first project
</Button>
</CreateNewProjectDialog>
</EmptyContent>
</Empty>
)}
{projects.map((p) => {
const projectdocuments = documents.filter(
(d) => d.project_id === p.id
);

return (
<div key={p.id} className="mb-40">
<header className="sticky top-0 py-5 mb-10 flex justify-between items-center border-b bg-background z-10">
<Link href={`/${organization.name}/${p.name}`}>
<h2 className="text-2xl font-bold">
{p.name}
<OpenInNewWindowIcon className="inline align-middle ms-2 size-5" />
</h2>
</Link>
<CreateNewDocumentButton
project_name={p.name}
project_id={p.id}
>
<Button className="gap-1">
<PlusIcon />
Create
<ChevronDownIcon />
</Button>
</CreateNewDocumentButton>
</header>
<DocumentsGrid
organization_name={organization.name}
project_name={p.name}
documents={projectdocuments}
layout={layout}
onChange={refresh}
/>
</div>
);
})}
</>
)}
<footer className="mt-10 mb-5">
<PoweredByGridaFooter />
</footer>
</div>
</main>
);
}

function ProjectsLoading() {
return (
<div className="w-full grid gap-2">
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
</div>
);
}
Loading
Loading