Cap rail bot and workspace lists at five with see-all summary
This commit is contained in:
@@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import profileImageUrls from "@/src/utils/profile-image-urls";
|
import profileImageUrls from "@/src/utils/profile-image-urls";
|
||||||
|
|
||||||
const WorkspaceAvatarSizes = {
|
const WorkspaceAvatarSizes = {
|
||||||
|
xs: { box: "size-[18px] rounded-[4px]", icon: 12 },
|
||||||
sm: { box: "size-8 rounded-default-sm", icon: 16 },
|
sm: { box: "size-8 rounded-default-sm", icon: 16 },
|
||||||
lg: { box: "size-14 rounded-default", icon: 24 },
|
lg: { box: "size-14 rounded-default", icon: 24 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -252,11 +252,16 @@ export const AppData = {
|
|||||||
* Worker bots the rail lists, most recently active first, before
|
* Worker bots the rail lists, most recently active first, before
|
||||||
* "See all bots".
|
* "See all bots".
|
||||||
*/
|
*/
|
||||||
RailRecentBots: 8,
|
RailRecentBots: 5,
|
||||||
/**
|
/**
|
||||||
* Stones shown beside "See all bots".
|
* Workspaces the rail lists, most recently active first, before
|
||||||
|
* "See all workspaces".
|
||||||
*/
|
*/
|
||||||
RailSeeAllStones: 4,
|
RailRecentWorkspaces: 5,
|
||||||
|
/**
|
||||||
|
* Stones shown beside "See all bots" and "See all workspaces".
|
||||||
|
*/
|
||||||
|
RailSeeAllStones: 3,
|
||||||
/**
|
/**
|
||||||
* Recent sessions the rail lists under the open bot, before "All
|
* Recent sessions the rail lists under the open bot, before "All
|
||||||
* sessions".
|
* sessions".
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import type { WorkspaceWithActivity } from "@/src/types";
|
||||||
|
import AddModalShell from "./add-modal-shell";
|
||||||
|
import AllWorkspacesRow from "./all-workspaces-row";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
workspaces: WorkspaceWithActivity[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every workspace, latest activity first, with a search by name or
|
||||||
|
* description. A workspace opens its page.
|
||||||
|
*/
|
||||||
|
export default function AllWorkspacesModal({ open, setOpen, workspaces }: Props) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const search = query.trim().toLowerCase();
|
||||||
|
const found = search
|
||||||
|
? workspaces.filter((workspace) =>
|
||||||
|
`${workspace.name} ${workspace.description || ""}`.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
: workspaces;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddModalShell open={open} setOpen={setOpen} title="Workspaces" description={`${workspaces.length} in all.`}>
|
||||||
|
<Input
|
||||||
|
id="all-workspaces-search"
|
||||||
|
title="Search workspaces"
|
||||||
|
placeholder="Search by name or description"
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
wrapperWrapperProps={{ className: "mt-4" }}
|
||||||
|
/>
|
||||||
|
<Stack className="w-full mt-2 max-h-[60vh] overflow-y-auto gap-0! items-stretch">
|
||||||
|
{found.map((workspace) => (
|
||||||
|
<AllWorkspacesRow key={workspace.id} workspace={workspace} />
|
||||||
|
))}
|
||||||
|
{found.length ? null : (
|
||||||
|
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
No workspace matches "{query.trim()}".
|
||||||
|
</Span>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Link from "@moduletrace/twui/layout/Link";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
|
import type { WorkspaceWithActivity } from "@/src/types";
|
||||||
|
import relativeTime from "@/src/utils/relative-time";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workspace: WorkspaceWithActivity;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One workspace in the all-workspaces modal: tile, name, description, last
|
||||||
|
* activity.
|
||||||
|
*/
|
||||||
|
export default function AllWorkspacesRow({ workspace }: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/admin/workspaces/${workspace.id}`}
|
||||||
|
title={workspace.name}
|
||||||
|
className="flex h-14 items-center gap-3 rounded-default px-3 border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||||
|
>
|
||||||
|
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
|
||||||
|
<Stack className="grow min-w-0 gap-0!">
|
||||||
|
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{workspace.name}</Span>
|
||||||
|
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||||
|
{workspace.unread ? "New messages" : workspace.description || ""}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
{workspace.last_activity_at ? (
|
||||||
|
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
|
||||||
|
{relativeTime({ at: Number(workspace.last_activity_at) })}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import AllBotsModal from "./all-bots-modal";
|
|||||||
import RailBotEntry from "./rail-bot-entry";
|
import RailBotEntry from "./rail-bot-entry";
|
||||||
import RailSectionLabel from "./rail-section-label";
|
import RailSectionLabel from "./rail-section-label";
|
||||||
import RailSeeAllRow from "./rail-see-all-row";
|
import RailSeeAllRow from "./rail-see-all-row";
|
||||||
|
import RailSeeAllStone from "./rail-see-all-stone";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -55,7 +56,9 @@ export default function RailBotsSection({ title, workers, selected_id, workspace
|
|||||||
)}
|
)}
|
||||||
{hidden.length ? (
|
{hidden.length ? (
|
||||||
<RailSeeAllRow
|
<RailSeeAllRow
|
||||||
hidden={hidden}
|
stones={hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
|
||||||
|
<RailSeeAllStone key={bot.id} bot={bot} />
|
||||||
|
))}
|
||||||
label={`See all ${workers.length} bots`}
|
label={`See all ${workers.length} bots`}
|
||||||
onOpen={() => setOpen(true)}
|
onOpen={() => setOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import { Ellipsis } from "lucide-react";
|
import { Ellipsis } from "lucide-react";
|
||||||
import { AppData } from "@/src/data/app-data";
|
|
||||||
import type { BotWithLastMessage } from "@/src/types";
|
|
||||||
import RailSeeAllStone from "./rail-see-all-stone";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
* The bots the rail doesn't list.
|
* A few of the records the rail doesn't list, e.g. their stones.
|
||||||
*/
|
*/
|
||||||
hidden: BotWithLastMessage[];
|
stones?: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Under the listed bots: a few stones of the ones that aren't listed and
|
* Under the listed records: a few stones of the ones that aren't listed and
|
||||||
* "See all bots", which opens them all. An ellipsis when the rail
|
* "See all", which opens every one in a modal. An ellipsis when the rail
|
||||||
* collapses.
|
* collapses.
|
||||||
*/
|
*/
|
||||||
export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
|
export default function RailSeeAllRow({ stones, label, onOpen }: Props) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
title={label}
|
title={label}
|
||||||
@@ -31,12 +29,10 @@ export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
|
|||||||
className="flex shrink-0 h-10 w-full items-center justify-center @rail:justify-start gap-3! rounded-default px-1! @rail:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
className="flex shrink-0 h-10 w-full items-center justify-center @rail:justify-start gap-3! rounded-default px-1! @rail:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||||
>
|
>
|
||||||
<Ellipsis size={18} aria-hidden className="@rail:hidden" />
|
<Ellipsis size={18} aria-hidden className="@rail:hidden" />
|
||||||
<Row className="hidden @rail:flex shrink-0 gap-1! flex-nowrap items-center">
|
{stones ? (
|
||||||
{hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
|
<Row className="hidden @rail:flex shrink-0 gap-1! flex-nowrap items-center">{stones}</Row>
|
||||||
<RailSeeAllStone key={bot.id} bot={bot} />
|
) : null}
|
||||||
))}
|
<Span className="hidden @rail:inline text-[13px] font-bold leading-4 truncate">{label}</Span>
|
||||||
</Row>
|
|
||||||
<Span className="hidden @rail:inline text-[13px] font-bold leading-4">{label}</Span>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
|
import type { WorkspaceWithActivity } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workspace: WorkspaceWithActivity;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One small workspace tile beside "See all workspaces".
|
||||||
|
*/
|
||||||
|
export default function RailSeeAllWorkspace({ workspace }: Props) {
|
||||||
|
return <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useContext } from "react";
|
import { useContext, useState } from "react";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
import type { WorkspaceWithActivity } from "@/src/types";
|
import type { WorkspaceWithActivity } from "@/src/types";
|
||||||
import { AddFlowContext } from "../(data)/add-flow-context";
|
import { AddFlowContext } from "../(data)/add-flow-context";
|
||||||
|
import AllWorkspacesModal from "./all-workspaces-modal";
|
||||||
import RailSectionAddButton from "./rail-section-add-button";
|
import RailSectionAddButton from "./rail-section-add-button";
|
||||||
import RailSectionLabel from "./rail-section-label";
|
import RailSectionLabel from "./rail-section-label";
|
||||||
|
import RailSeeAllRow from "./rail-see-all-row";
|
||||||
|
import RailSeeAllWorkspace from "./rail-see-all-workspace";
|
||||||
import WorkspaceRow from "./workspace-row";
|
import WorkspaceRow from "./workspace-row";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,10 +16,15 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user's workspaces, with a "+" to create one.
|
* The user's workspaces, with a "+" to create one: the latest
|
||||||
|
* `AppData.RailRecentWorkspaces`, then "See all workspaces" for the rest,
|
||||||
|
* which lists them all in a modal.
|
||||||
*/
|
*/
|
||||||
export default function RailWorkspacesSection({ workspaces, selected_id }: Props) {
|
export default function RailWorkspacesSection({ workspaces, selected_id }: Props) {
|
||||||
const { openForm } = useContext(AddFlowContext);
|
const { openForm } = useContext(AddFlowContext);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const shown = workspaces.slice(0, AppData.RailRecentWorkspaces);
|
||||||
|
const hidden = workspaces.slice(AppData.RailRecentWorkspaces);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -28,7 +37,7 @@ export default function RailWorkspacesSection({ workspaces, selected_id }: Props
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{workspaces.map((workspace) => (
|
{shown.map((workspace) => (
|
||||||
<WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} />
|
<WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} />
|
||||||
))}
|
))}
|
||||||
{workspaces.length ? null : (
|
{workspaces.length ? null : (
|
||||||
@@ -36,6 +45,16 @@ export default function RailWorkspacesSection({ workspaces, selected_id }: Props
|
|||||||
No workspaces yet. A workspace keeps a project's context, bots and computers together.
|
No workspaces yet. A workspace keeps a project's context, bots and computers together.
|
||||||
</Span>
|
</Span>
|
||||||
)}
|
)}
|
||||||
|
{hidden.length ? (
|
||||||
|
<RailSeeAllRow
|
||||||
|
stones={hidden.slice(0, AppData.RailSeeAllStones).map((workspace) => (
|
||||||
|
<RailSeeAllWorkspace key={workspace.id} workspace={workspace} />
|
||||||
|
))}
|
||||||
|
label={`See all ${workspaces.length} workspaces`}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AllWorkspacesModal open={open} setOpen={setOpen} workspaces={workspaces} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user