Skip to content
100 linesCodeBlameRaw
1import { DOC_ROLE_LABELS, type DocSpace, type FolioList } from "@g1t/contracts";
2import { FilePlus2, Settings } from "lucide-react";
3import { useState } from "react";
4import { Form, Link, data, useNavigation } from "react-router";
5
6import type { Route } from "./+types/space";
7import { useFoliosAction, useFoliosData, useViewerZone } from "../../../components/folios/actions";
8import { FolioDays } from "../../../components/folios/list";
9import { SpaceIcon, spaceKindLabel } from "../../../components/folios/parts";
10import { EmptyState, ErrorText } from "../../../components/ui";
11import { Hint } from "../../../components/ui/hint";
12import { canDo, spacePath } from "../../../lib/folios";
13import { page } from "../../../lib/meta";
14import { docs, folios } from "../../../lib/services.server";
15import { requireUser, roleIn } from "../../../lib/session.server";
16
17export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t`, description: loaded?.space.description ?? undefined });
19}
20
21export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; list: FolioList | null }> {
22 const viewer = requireUser(context, request);
23 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24 const slug = params.owner.toLowerCase();
25 const found = await docs.space(slug, params.space, viewer).catch(() => null);
26 if (!found?.ok || found.value.space.archived_at) throw data(null, { status: 404 });
27 const list = await folios
28 .list(slug, viewer, { tab: "all", space_id: found.value.space.id, limit: 100 })
29 .then((r) => (r.ok ? r.value : null))
30 .catch(() => null);
31 return { space: found.value.space, list };
32}
33
34/** A space: who it's for, joining it (an open one), making a doc in it, and everything in it the viewer can open, by day. */
35export default function SpacePage({ loaderData, params }: Route.ComponentProps) {
36 const slug = params.owner.toLowerCase();
37 const { space, list } = loaderData;
38 const layout = useFoliosData();
39 const zone = useViewerZone();
40 const { send, busy, error } = useFoliosAction(slug);
41 const [failed, setFailed] = useState<string | null>(null);
42 const starting = useNavigation().state !== "idle";
43 const joined = (layout?.sidebar?.spaces ?? []).some((s) => s.id === space.id);
44 const joinable = space.kind === "workspace" && !space.is_default;
45 const who = space.kind === "workspace" ? `Everyone in the workspace (${space.default_role ? DOC_ROLE_LABELS[space.default_role].toLowerCase() : "listed members only"})` : space.kind === "team" ? `The ${space.team} team, and members added to it` : "Only its members";
46 return (
47 <div className="mx-auto max-w-5xl">
48 <div className="flex flex-wrap items-start gap-4">
49 <span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-raised text-2xl">
50 <SpaceIcon space={space} size={22} />
51 </span>
52 <div className="min-w-0 grow">
53 <h1 className="truncate text-2xl font-semibold tracking-tight">{space.name}</h1>
54 <p className="mt-1 text-sm text-muted">{space.description || "No description."}</p>
55 <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
56 <span className="rounded-full px-2 py-0.5 ring-1 ring-line">{spaceKindLabel(space.kind)}</span>
57 <span>{who}</span>
58 <span>Agents here: {space.agent_mode === "edit" ? "edit directly" : "suggest changes"}</span>
59 </p>
60 </div>
61 <div className="flex items-center gap-2">
62 {joinable && (
63 <button type="button" disabled={busy} onClick={() => send(joined ? "leave_space" : "join_space", { space_id: space.id })} className="inline-flex h-9 items-center rounded-md border border-line px-3 text-sm text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-60">
64 {joined ? "Leave" : "Join"}
65 </button>
66 )}
67 {canDo(space.viewer_role, "manage") && (
68 <Hint label="Space settings">
69 <Link to={`${spacePath(slug, space.slug)}/settings`} aria-label="Space settings" className="flex size-9 items-center justify-center rounded-md border border-line text-muted hover:border-line-strong hover:bg-surface hover:text-fg">
70 <Settings size={16} />
71 </Link>
72 </Hint>
73 )}
74 {canDo(space.viewer_role, "edit") && (
75 <Form method="post" action={`/${slug}/-/artifacts/new/doc`}>
76 <input type="hidden" name="space" value={space.id} />
77 <button type="submit" disabled={starting} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg hover:bg-accent-hover disabled:opacity-60">
78 <FilePlus2 size={15} /> {starting ? "Starting…" : "New doc"}
79 </button>
80 </Form>
81 )}
82 </div>
83 </div>
84 {(error || failed) && (
85 <div className="mt-3">
86 <ErrorText>{error ?? failed}</ErrorText>
87 </div>
88 )}
89 <div className="mt-8 mb-8">
90 {!list ? (
91 <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
92 ) : list.items.length === 0 ? (
93 <EmptyState title="Nothing here yet">{canDo(space.viewer_role, "edit") ? "Start a doc with New doc, or move something here from its ⋯ menu." : "When someone adds an artifact here, it shows up for you."}</EmptyState>
94 ) : (
95 <FolioDays slug={slug} items={list.items} zone={zone} onError={setFailed} />
96 )}
97 </div>
98 </div>
99 );
100}