Build Cobble project structure from the handoff
- Remove the preset document suite (letters, emails, invoices, quotes, PDF generation, line items) with its routes, API handlers and tables - Rebrand to Cobble: site data, Lato, handoff design tokens (light and dark) in main.css, brand assets in public/brand, ember stone logo and regenerated favicons - Add bots, bot_messages, bot_tasks and bot_task_steps tables, regenerate DB types (BUN_SQLITE_COBBLE_*) and add a sample crew seed (seed:bots) - Add the chat shell at /admin/bots: rail with pinned coordinator and user popover, bottom-anchored conversation with composer, working-on panel, and a send-message API scoped to the session user - Add BotStone and Wordmark components, crew status and relative time utils with tests, and update AGENTS.md Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
import StoneEyeDot from "./stone-eye-dot";
|
||||
import StoneEyeLidded from "./stone-eye-lidded";
|
||||
import StoneEyeArc from "./stone-eye-arc";
|
||||
import StoneEyeFlat from "./stone-eye-flat";
|
||||
|
||||
type Props = {
|
||||
face: BotFace;
|
||||
/**
|
||||
* Fill class for solid eyes.
|
||||
*/
|
||||
fill_class: string;
|
||||
/**
|
||||
* Stroke class for arc eyes.
|
||||
*/
|
||||
stroke_class: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The face is the status. Geometry follows the handoff face states:
|
||||
* idle dots, working half-lidded, done upward arcs, asleep one flattened
|
||||
* dot, needs-you dot plus arc.
|
||||
*/
|
||||
export default function BotStoneFace({ face, fill_class, stroke_class }: Props) {
|
||||
const { left, right, centre } = StoneEyes;
|
||||
|
||||
switch (face) {
|
||||
case "working":
|
||||
return (
|
||||
<>
|
||||
<StoneEyeLidded {...left} className={fill_class} />
|
||||
<StoneEyeLidded {...right} className={fill_class} />
|
||||
</>
|
||||
);
|
||||
case "done":
|
||||
return (
|
||||
<>
|
||||
<StoneEyeArc {...left} className={stroke_class} />
|
||||
<StoneEyeArc {...right} className={stroke_class} />
|
||||
</>
|
||||
);
|
||||
case "asleep":
|
||||
return <StoneEyeFlat {...centre} className={fill_class} />;
|
||||
case "needs-you":
|
||||
return (
|
||||
<>
|
||||
<StoneEyeDot {...left} className={fill_class} />
|
||||
<StoneEyeArc {...right} className={stroke_class} />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<StoneEyeDot {...left} className={fill_class} />
|
||||
<StoneEyeDot {...right} className={fill_class} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotHueFillClasses } from "@/src/dict/bot-hue-classes-dict";
|
||||
import {
|
||||
CoordinatorBodyPath,
|
||||
StoneBodyPaths,
|
||||
StoneViewBox,
|
||||
} from "@/src/data/stone-geometry";
|
||||
import BotStoneFace from "./bot-stone-face";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The bot's identity hue. Omit (or `null`) for the coordinator, which
|
||||
* renders hue-less: ink body, paper eyes, inverted in dark mode.
|
||||
*/
|
||||
hue?: BotHue | null;
|
||||
face?: BotFace;
|
||||
size?: number;
|
||||
/**
|
||||
* Accessible name. Leave empty when a visible bot name sits beside the
|
||||
* stone (the default), so the stone is decorative.
|
||||
*/
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Bot Stone
|
||||
* @description A Cobble bot avatar: a stone with exactly one flat cut and two
|
||||
* eyes. Never put it in a circle, ring or badge, or add an outline or shadow.
|
||||
*/
|
||||
export default function BotStone({
|
||||
hue,
|
||||
face = "idle",
|
||||
size = 32,
|
||||
label,
|
||||
className,
|
||||
}: Props) {
|
||||
const is_coordinator = !hue;
|
||||
const body_path = hue ? StoneBodyPaths[hue] : CoordinatorBodyPath;
|
||||
const body_class = hue ? BotHueFillClasses[hue] : "fill-ink";
|
||||
const eye_fill = is_coordinator ? "fill-paper" : "fill-dark";
|
||||
const eye_stroke = is_coordinator ? "stroke-paper" : "stroke-dark";
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={StoneViewBox}
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label || undefined}
|
||||
aria-hidden={label ? undefined : true}
|
||||
className={twMerge("shrink-0", className)}
|
||||
>
|
||||
<path d={body_path} className={body_class} />
|
||||
<BotStoneFace
|
||||
face={face}
|
||||
fill_class={eye_fill}
|
||||
stroke_class={eye_stroke}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
import type { TableType } from "@/src/types";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Card from "@moduletrace/twui/elements/Card";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import H4 from "@moduletrace/twui/layout/H4";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import dayjs from "dayjs";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||
|
||||
export type CopyableRecord = {
|
||||
id?: number | "";
|
||||
title?: string;
|
||||
subject?: string;
|
||||
};
|
||||
|
||||
type Props<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
|
||||
table: TableType;
|
||||
title: string;
|
||||
onSelect?: (record: T) => void;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export default function CopyFromExistingSelect<
|
||||
T extends BUN_SQLITE_BSQLITE_ALL_TYPEDEFS =
|
||||
BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
|
||||
>({ table, title, onSelect, onClear }: Props<T>) {
|
||||
const { res: records, ready } = useAdminCrudGet<T>({
|
||||
table,
|
||||
});
|
||||
|
||||
const { open, setOpen } = useStatus();
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Button
|
||||
title="Clear Form"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClear?.();
|
||||
}}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
target={
|
||||
<Button
|
||||
title="Duplicate Existing Record"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Create from an Existing Record
|
||||
</Button>
|
||||
}
|
||||
className="xl:p-6"
|
||||
>
|
||||
<Stack className="gap-6 w-full">
|
||||
<Stack className="gap-1">
|
||||
<H3 className="text-2xl! font-semibold">
|
||||
{title || "Select Record"}
|
||||
</H3>
|
||||
<Span>Select a record to duplicate</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full items-stretch">
|
||||
{records?.[0] ? (
|
||||
records?.map((rec, index) => {
|
||||
const date = dayjs(
|
||||
twuiNumberfy(rec.created_at),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onSelect?.(rec);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<Row className="w-full justify-between">
|
||||
<Row>
|
||||
<LucideIcon
|
||||
name="Database"
|
||||
size={17}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<H4 className="text-base! font-semibold">
|
||||
{rec.title}
|
||||
</H4>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Span
|
||||
size="smaller"
|
||||
variant="faded"
|
||||
className="text-nowrap"
|
||||
>
|
||||
{date
|
||||
.toString()
|
||||
.substring(0, 16)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyContent title="No Records Found!" />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,22 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import BotStone from "./bot-stone";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
img_props?: Pick<ComponentProps<"svg">, "className">;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Cobble mark: the ember stone. Source of truth is
|
||||
* `public/logos/logo.svg` (copied from `public/brand/icons/ember.svg`).
|
||||
*/
|
||||
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={icon_size}
|
||||
height={icon_size}
|
||||
viewBox="0 0 512 512"
|
||||
role="img"
|
||||
aria-label="Logo Icon"
|
||||
className={twMerge("shrink-0", img_props?.className)}
|
||||
>
|
||||
<rect
|
||||
x="16"
|
||||
y="16"
|
||||
width="480"
|
||||
height="480"
|
||||
rx="120"
|
||||
className="fill-slate-900 dark:fill-slate-800"
|
||||
/>
|
||||
<path
|
||||
d="M290 100 L144 286 H244 L222 412 L368 226 H268 Z"
|
||||
strokeWidth="28"
|
||||
strokeLinejoin="round"
|
||||
className="fill-white stroke-white"
|
||||
/>
|
||||
</svg>
|
||||
<BotStone
|
||||
hue="ember"
|
||||
size={icon_size}
|
||||
label="Logo Icon"
|
||||
className={img_props?.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import type Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import LogoIcon from "./logo-icon";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import Wordmark from "./wordmark";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
text_wrapper_props?: ComponentProps<typeof Stack>;
|
||||
/**
|
||||
* Only `className` applies: it sets the wordmark colour.
|
||||
*/
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal lockup: the ember stone beside the `cobble` wordmark.
|
||||
*/
|
||||
export default function Logo({
|
||||
icon_size = 36,
|
||||
text_props,
|
||||
@@ -19,38 +25,18 @@ export default function Logo({
|
||||
}: Props) {
|
||||
return (
|
||||
<a href="/" className="logo-link">
|
||||
<Row className="gap-3! flex-nowrap items-center">
|
||||
<Row className="gap-2.5! flex-nowrap items-center">
|
||||
<LogoIcon icon_size={icon_size} />
|
||||
<Stack
|
||||
{...text_wrapper_props}
|
||||
className={twMerge("gap-1!", text_wrapper_props?.className)}
|
||||
className={twMerge("gap-0!", text_wrapper_props?.className)}
|
||||
>
|
||||
<Stack className="gap-0!">
|
||||
<HeroText text_props={text_props}>
|
||||
{SiteData["SiteName"]}
|
||||
</HeroText>
|
||||
</Stack>
|
||||
<Wordmark
|
||||
height={Math.round(icon_size * 0.5)}
|
||||
className={text_props?.className}
|
||||
/>
|
||||
</Stack>
|
||||
</Row>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroText({
|
||||
text_props,
|
||||
children,
|
||||
}: PropsWithChildren & {
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
}) {
|
||||
return (
|
||||
<Span
|
||||
{...text_props}
|
||||
className={twMerge(
|
||||
"text-[17px] font-semibold tracking-tight text-dark dark:text-white heading leading-[24px] whitespace-nowrap",
|
||||
text_props?.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
body: any;
|
||||
download_name?: string;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export default function PdfPreview({
|
||||
url,
|
||||
body,
|
||||
download_name,
|
||||
height = 1200,
|
||||
}: Props) {
|
||||
const [blob_url, setBlobUrl] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let object_url: string | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
setBlobUrl(undefined);
|
||||
setError(undefined);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const content_type = res.headers.get("Content-Type") || "";
|
||||
|
||||
if (content_type.includes("application/json")) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.msg || "Couldn't generate PDF");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
object_url = URL.createObjectURL(blob);
|
||||
setBlobUrl(object_url);
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setError(error.message || "Couldn't generate PDF preview");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
if (object_url) {
|
||||
URL.revokeObjectURL(object_url);
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Span className="text-warning">{error}</Span>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blob_url) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Loading />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 items-end">
|
||||
{download_name ? (
|
||||
<a
|
||||
href={blob_url}
|
||||
download={download_name}
|
||||
className="text-sm text-link-500 hover:opacity-70 underline"
|
||||
>
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
<iframe
|
||||
src={blob_url}
|
||||
title="PDF Preview"
|
||||
className="w-full rounded-default border border-slate-300 dark:border-white/20 bg-white"
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Border from "@moduletrace/twui/elements/Border";
|
||||
import { formatNaira } from "@/src/utils/money";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
|
||||
export type SortableLineItemFormObject = {
|
||||
description?: string;
|
||||
quantity?: string;
|
||||
/** Naira amount as a plain number string (converted to kobo on submit). */
|
||||
unit_price?: string;
|
||||
/** Client-side row identity for stable React keys during reorder. */
|
||||
_key?: string;
|
||||
};
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
item: T;
|
||||
index: number;
|
||||
placeholder: string;
|
||||
is_dragging: boolean;
|
||||
is_drop_target: boolean;
|
||||
/** Whether a drag is currently in progress anywhere in the list. */
|
||||
drag_active: boolean;
|
||||
can_move_up: boolean;
|
||||
can_move_down: boolean;
|
||||
onChange: (item: T) => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: () => void;
|
||||
onDragLeave: () => void;
|
||||
/** `drop_after` is true when dropped on the lower half of this row. */
|
||||
onDrop: (drop_after: boolean) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemRow<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({
|
||||
item,
|
||||
index,
|
||||
placeholder,
|
||||
is_dragging,
|
||||
is_drop_target,
|
||||
drag_active,
|
||||
can_move_up,
|
||||
can_move_down,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: Props<T>) {
|
||||
const line_total_kobo =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0) * 100;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={[
|
||||
"w-full gap-3!",
|
||||
is_dragging && "opacity-60",
|
||||
is_drop_target && "ring-2 ring-primary/70",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onDragOver={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
onDragOver();
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_active) onDragLeave();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
onDrop(e.clientY - rect.top > rect.height / 2);
|
||||
}}
|
||||
>
|
||||
<Row className="w-full justify-between items-center gap-2!">
|
||||
<Row className="gap-2! items-center">
|
||||
<Span
|
||||
title="Drag to reorder"
|
||||
className="cursor-grab select-none p-1 -m-1!"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<LucideIcon name="GripVertical" size={16} />
|
||||
</Span>
|
||||
<Span className="text-xs font-semibold opacity-60">
|
||||
Item #{index + 1}
|
||||
</Span>
|
||||
</Row>
|
||||
<Row className="gap-1! items-center">
|
||||
<Button
|
||||
title="Move Up"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_up}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<LucideIcon name="ArrowUp" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Move Down"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_down}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<LucideIcon name="ArrowDown" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Remove Line Item"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="smaller"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<LucideIcon name="Trash2" size={14} />
|
||||
</Button>
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<Input
|
||||
title="Description"
|
||||
placeholder={placeholder}
|
||||
defaultValue={item.description}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, description: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Row className="w-full grid xl:grid-cols-3 items-stretch">
|
||||
<Input
|
||||
title="Quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item.quantity}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, quantity: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Input
|
||||
title="Unit Price (₦)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
defaultValue={item.unit_price}
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...item,
|
||||
unit_price: Math.round(
|
||||
twuiNumberfy(e.target.value),
|
||||
).toString(),
|
||||
});
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Border className="py-1 px-4">
|
||||
<Span className="text-sm font-semibold">
|
||||
{formatNaira(line_total_kobo)}
|
||||
</Span>
|
||||
</Border>
|
||||
</Row>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
|
||||
import SortableLineItemRow, {
|
||||
type SortableLineItemFormObject,
|
||||
} from "./sortable-line-item-row";
|
||||
|
||||
let row_key_counter = 0;
|
||||
|
||||
export const makeRowKey = (): string =>
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `row-${Date.now()}-${row_key_counter++}`;
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
items: T[];
|
||||
empty_item: T;
|
||||
item_placeholder: string;
|
||||
onChange: (items: T[]) => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemsEditor<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({ items, empty_item, item_placeholder, onChange }: Props<T>) {
|
||||
const [drag_source, set_drag_source] = useState<number | undefined>();
|
||||
const [drag_target, set_drag_target] = useState<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (items.some((item) => !item._key)) {
|
||||
onChange(
|
||||
items.map((item) => ({ ...item, _key: makeRowKey() })),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const display_items = items.length ? items : [empty_item];
|
||||
const can_remove = items.length > 1;
|
||||
|
||||
const setItems = (next_items: T[]) => onChange(next_items);
|
||||
|
||||
const endDrag = () => {
|
||||
set_drag_source(undefined);
|
||||
set_drag_target(undefined);
|
||||
};
|
||||
|
||||
const moveTo = (from: number | undefined, target: number, drop_after: boolean) => {
|
||||
if (from === undefined || from === target) return;
|
||||
|
||||
let to = drop_after ? target + 1 : target;
|
||||
if (from < to) to -= 1;
|
||||
if (to === from) return;
|
||||
|
||||
const next_items = items.length ? [...items] : [{ ...empty_item }];
|
||||
const moved_item: T = next_items[from] as T;
|
||||
next_items.splice(from, 1);
|
||||
next_items.splice(to, 0, moved_item);
|
||||
setItems(next_items);
|
||||
};
|
||||
|
||||
const moveBy = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= (items.length ? items.length : 1)) return;
|
||||
moveTo(index, target, delta === 1);
|
||||
};
|
||||
|
||||
const total_kobo = sumInvoiceItemsKobo(
|
||||
items.map((item) => ({
|
||||
quantity: item.quantity,
|
||||
unit_price_kobo: (Number(item.unit_price) || 0) * 100,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-4">
|
||||
<H3 className="!mb-0">Line Items</H3>
|
||||
|
||||
{display_items.map((item, index) => (
|
||||
<SortableLineItemRow
|
||||
key={item._key || index}
|
||||
item={item}
|
||||
index={index}
|
||||
placeholder={item_placeholder}
|
||||
is_dragging={drag_source === index}
|
||||
is_drop_target={
|
||||
drag_source !== undefined &&
|
||||
drag_source !== index &&
|
||||
drag_target === index
|
||||
}
|
||||
drag_active={drag_source !== undefined}
|
||||
can_move_up={index > 0}
|
||||
can_move_down={index < items.length - 1}
|
||||
onChange={(next_item) => {
|
||||
const next_items = items.length
|
||||
? [...items]
|
||||
: [{ ...empty_item }];
|
||||
next_items[index] = next_item;
|
||||
setItems(next_items);
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (!can_remove) return;
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
}}
|
||||
onMoveUp={() => moveBy(index, -1)}
|
||||
onMoveDown={() => moveBy(index, 1)}
|
||||
onDragStart={() => {
|
||||
if (items.length < 2 || drag_source !== undefined) return;
|
||||
set_drag_source(index);
|
||||
}}
|
||||
onDragOver={() => {
|
||||
if (
|
||||
drag_source === undefined ||
|
||||
drag_source === index ||
|
||||
drag_target === index
|
||||
) return;
|
||||
set_drag_target(index);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_target === index) {
|
||||
set_drag_target(undefined);
|
||||
}
|
||||
}}
|
||||
onDrop={(drop_after) => {
|
||||
moveTo(drag_source, index, drop_after);
|
||||
endDrag();
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Row className="w-full justify-between items-center">
|
||||
<Button
|
||||
title="Add Line Item"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setItems([
|
||||
...items,
|
||||
{ ...empty_item, _key: makeRowKey() },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add Line Item
|
||||
</Button>
|
||||
|
||||
<Span className="text-base font-semibold">
|
||||
Total: {formatNaira(total_kobo)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upward arc eye, drawn as a stroke in the eye colour.
|
||||
*/
|
||||
export default function StoneEyeArc({ cx, cy, className }: Props) {
|
||||
const r = StoneEyes.radius;
|
||||
const base = cy + r * 0.4;
|
||||
|
||||
return (
|
||||
<path
|
||||
d={`M ${cx - r} ${base} A ${r} ${r} 0 0 1 ${cx + r} ${base}`}
|
||||
fill="none"
|
||||
strokeWidth={2.6}
|
||||
strokeLinecap="round"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function StoneEyeDot({ cx, cy, className }: Props) {
|
||||
return <circle cx={cx} cy={cy} r={StoneEyes.radius} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattened dot, used once and centred for the asleep face.
|
||||
*/
|
||||
export default function StoneEyeFlat({ cx, cy, className }: Props) {
|
||||
return (
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={StoneEyes.radius + 1}
|
||||
ry={1.8}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { StoneEyes } from "@/src/data/stone-geometry";
|
||||
|
||||
type Props = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Half-lidded eye: the lower half of the dot, flat edge on top.
|
||||
*/
|
||||
export default function StoneEyeLidded({ cx, cy, className }: Props) {
|
||||
const r = StoneEyes.radius;
|
||||
|
||||
return (
|
||||
<path
|
||||
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 0 ${cx + r} ${cy} Z`}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import { WordmarkPaths, WordmarkViewBox } from "@/src/data/wordmark-paths";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Rendered height in px. The plain wordmark is for 16 to 48 px.
|
||||
*/
|
||||
height?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const [, , VIEWBOX_WIDTH, VIEWBOX_HEIGHT] = WordmarkViewBox.split(" ").map(
|
||||
Number,
|
||||
) as [number, number, number, number];
|
||||
|
||||
/**
|
||||
* # Wordmark
|
||||
* @description The `cobble` wordmark as vector paths, never live text. Takes
|
||||
* the current text colour.
|
||||
*/
|
||||
export default function Wordmark({ height = 15, className }: Props) {
|
||||
const width = (height * VIEWBOX_WIDTH) / VIEWBOX_HEIGHT;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={WordmarkViewBox}
|
||||
role="img"
|
||||
aria-label={SiteData["SiteName"]}
|
||||
className={twMerge("shrink-0 fill-current text-ink", className)}
|
||||
>
|
||||
{WordmarkPaths.map((d) => (
|
||||
<WordmarkPath key={d.slice(0, 32)} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WordmarkPath({ d }: { d: string }) {
|
||||
return <path d={d} />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const SiteData = {
|
||||
SiteName: `Bun SQLite Preset`,
|
||||
SiteURL: `http://localhost:7247`,
|
||||
SiteSlug: `bsqlite`,
|
||||
SiteName: `cobble`,
|
||||
SiteURL: `https://cobble.moduletrace.org`,
|
||||
SiteSlug: `cobble`,
|
||||
} as const;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* `cobble-plain` wordmark paths from the handoff
|
||||
* (`public/brand/wordmark/cobble-plain.svg`), cropped to the glyph bounds.
|
||||
* Use from 16 to 48 px height; never stretch, re-space or recolour it.
|
||||
*/
|
||||
export const WordmarkViewBox = "516.9 124.3 566.2 151.3";
|
||||
|
||||
export const WordmarkPaths = [
|
||||
"M598.5 194.95 C597.567 196.083 596.667 196.983 595.8 197.65 C594.933 198.317 593.7 198.65 592.1 198.65 C590.567 198.65 589.183 198.283 587.95 197.55 C586.717 196.817 585.333 196 583.8 195.1 C582.267 194.2 580.467 193.383 578.4 192.65 C576.333 191.917 573.767 191.55 570.7 191.55 C566.9 191.55 563.617 192.25 560.85 193.65 C558.083 195.05 555.8 197.05 554 199.65 C552.2 202.25 550.867 205.433 550 209.2 C549.133 212.967 548.7 217.217 548.7 221.95 C548.7 231.883 550.617 239.517 554.45 244.85 C558.283 250.183 563.567 252.85 570.3 252.85 C573.9 252.85 576.75 252.4 578.85 251.5 C580.95 250.6 582.733 249.6 584.2 248.5 C585.667 247.4 587.017 246.383 588.25 245.45 C589.483 244.517 591.033 244.05 592.9 244.05 C595.367 244.05 597.233 244.95 598.5 246.75 L607.5 257.85 C604.3 261.517 600.933 264.5 597.4 266.8 C593.867 269.1 590.25 270.9 586.55 272.2 C582.85 273.5 579.133 274.4 575.4 274.9 C571.667 275.4 568.033 275.65 564.5 275.65 C558.167 275.65 552.117 274.45 546.35 272.05 C540.583 269.65 535.517 266.167 531.15 261.6 C526.783 257.033 523.317 251.417 520.75 244.75 C518.183 238.083 516.9 230.483 516.9 221.95 C516.9 214.483 518.017 207.5 520.25 201 C522.483 194.5 525.783 188.867 530.15 184.1 C534.517 179.333 539.917 175.583 546.35 172.85 C552.783 170.117 560.233 168.75 568.7 168.75 C576.833 168.75 583.967 170.05 590.1 172.65 C596.233 175.25 601.767 179.05 606.7 184.05 Z",
|
||||
"M660.6 168.75 C668.467 168.75 675.633 169.983 682.1 172.45 C688.567 174.917 694.117 178.45 698.75 183.05 C703.383 187.65 706.983 193.233 709.55 199.8 C712.117 206.367 713.4 213.75 713.4 221.95 C713.4 230.217 712.117 237.667 709.55 244.3 C706.983 250.933 703.383 256.567 698.75 261.2 C694.117 265.833 688.567 269.4 682.1 271.9 C675.633 274.4 668.467 275.65 660.6 275.65 C652.667 275.65 645.45 274.4 638.95 271.9 C632.45 269.4 626.85 265.833 622.15 261.2 C617.45 256.567 613.817 250.933 611.25 244.3 C608.683 237.667 607.4 230.217 607.4 221.95 C607.4 213.75 608.683 206.367 611.25 199.8 C613.817 193.233 617.45 187.65 622.15 183.05 C626.85 178.45 632.45 174.917 638.95 172.45 C645.45 169.983 652.667 168.75 660.6 168.75 Z M660.6 252.65 C667.733 252.65 672.983 250.133 676.35 245.1 C679.717 240.067 681.4 232.417 681.4 222.15 C681.4 211.883 679.717 204.25 676.35 199.25 C672.983 194.25 667.733 191.75 660.6 191.75 C653.267 191.75 647.9 194.25 644.5 199.25 C641.1 204.25 639.4 211.883 639.4 222.15 C639.4 232.417 641.1 240.067 644.5 245.1 C647.9 250.133 653.267 252.65 660.6 252.65 Z",
|
||||
"M724 274.05 L724 124.35 L755 124.35 L755 181.45 C759 177.583 763.367 174.5 768.1 172.2 C772.833 169.9 778.167 168.75 784.1 168.75 C789.967 168.75 795.3 169.95 800.1 172.35 C804.9 174.75 809 178.167 812.4 182.6 C815.8 187.033 818.417 192.383 820.25 198.65 C822.083 204.917 823 211.917 823 219.65 C823 228.05 821.933 235.683 819.8 242.55 C817.667 249.417 814.65 255.3 810.75 260.2 C806.85 265.1 802.2 268.9 796.8 271.6 C791.4 274.3 785.433 275.65 778.9 275.65 C775.767 275.65 772.967 275.35 770.5 274.75 C768.033 274.15 765.767 273.3 763.7 272.2 C761.633 271.1 759.75 269.783 758.05 268.25 C756.35 266.717 754.7 265.05 753.1 263.25 L752 268.45 C751.467 270.583 750.6 272.05 749.4 272.85 C748.2 273.65 746.6 274.05 744.6 274.05 Z M773.2 192.05 C769.4 192.05 766.1 192.883 763.3 194.55 C760.5 196.217 757.733 198.65 755 201.85 L755 245.85 C757.333 248.45 759.867 250.267 762.6 251.3 C765.333 252.333 768.1 252.85 770.9 252.85 C773.833 252.85 776.533 252.3 779 251.2 C781.467 250.1 783.583 248.3 785.35 245.8 C787.117 243.3 788.5 240 789.5 235.9 C790.5 231.8 791 226.75 791 220.75 C791 215.55 790.617 211.133 789.85 207.5 C789.083 203.867 787.95 200.9 786.45 198.6 C784.95 196.3 783.083 194.633 780.85 193.6 C778.617 192.567 776.067 192.05 773.2 192.05 Z",
|
||||
"M833.9 274.05 L833.9 124.35 L864.9 124.35 L864.9 181.45 C868.9 177.583 873.267 174.5 878 172.2 C882.733 169.9 888.067 168.75 894 168.75 C899.867 168.75 905.2 169.95 910 172.35 C914.8 174.75 918.9 178.167 922.3 182.6 C925.7 187.033 928.317 192.383 930.15 198.65 C931.983 204.917 932.9 211.917 932.9 219.65 C932.9 228.05 931.833 235.683 929.7 242.55 C927.567 249.417 924.55 255.3 920.65 260.2 C916.75 265.1 912.1 268.9 906.7 271.6 C901.3 274.3 895.333 275.65 888.8 275.65 C885.667 275.65 882.867 275.35 880.4 274.75 C877.933 274.15 875.667 273.3 873.6 272.2 C871.533 271.1 869.65 269.783 867.95 268.25 C866.25 266.717 864.6 265.05 863 263.25 L861.9 268.45 C861.367 270.583 860.5 272.05 859.3 272.85 C858.1 273.65 856.5 274.05 854.5 274.05 Z M883.1 192.05 C879.3 192.05 876 192.883 873.2 194.55 C870.4 196.217 867.633 198.65 864.9 201.85 L864.9 245.85 C867.233 248.45 869.767 250.267 872.5 251.3 C875.233 252.333 878 252.85 880.8 252.85 C883.733 252.85 886.433 252.3 888.9 251.2 C891.367 250.1 893.483 248.3 895.25 245.8 C897.017 243.3 898.4 240 899.4 235.9 C900.4 231.8 900.9 226.75 900.9 220.75 C900.9 215.55 900.517 211.133 899.75 207.5 C898.983 203.867 897.85 200.9 896.35 198.6 C894.85 196.3 892.983 194.633 890.75 193.6 C888.517 192.567 885.967 192.05 883.1 192.05 Z",
|
||||
"M974.8 124.35 L974.8 274.05 L943.8 274.05 L943.8 124.35 Z",
|
||||
"M1036.5 168.75 C1043.367 168.75 1049.65 169.817 1055.35 171.95 C1061.05 174.083 1065.95 177.183 1070.05 181.25 C1074.15 185.317 1077.35 190.3 1079.65 196.2 C1081.95 202.1 1083.1 208.783 1083.1 216.25 C1083.1 218.583 1083 220.483 1082.8 221.95 C1082.6 223.417 1082.233 224.583 1081.7 225.45 C1081.167 226.317 1080.45 226.917 1079.55 227.25 C1078.65 227.583 1077.5 227.75 1076.1 227.75 L1016.3 227.75 C1017.3 236.417 1019.933 242.7 1024.2 246.6 C1028.467 250.5 1033.967 252.45 1040.7 252.45 C1044.3 252.45 1047.4 252.017 1050 251.15 C1052.6 250.283 1054.917 249.317 1056.95 248.25 C1058.983 247.183 1060.85 246.217 1062.55 245.35 C1064.25 244.483 1066.033 244.05 1067.9 244.05 C1070.367 244.05 1072.233 244.95 1073.5 246.75 L1082.5 257.85 C1079.3 261.517 1075.85 264.5 1072.15 266.8 C1068.45 269.1 1064.65 270.9 1060.75 272.2 C1056.85 273.5 1052.95 274.4 1049.05 274.9 C1045.15 275.4 1041.433 275.65 1037.9 275.65 C1030.633 275.65 1023.817 274.467 1017.45 272.1 C1011.083 269.733 1005.517 266.217 1000.75 261.55 C995.983 256.883 992.217 251.083 989.45 244.15 C986.683 237.217 985.3 229.15 985.3 219.95 C985.3 213.017 986.483 206.45 988.85 200.25 C991.217 194.05 994.617 188.617 999.05 183.95 C1003.483 179.283 1008.85 175.583 1015.15 172.85 C1021.45 170.117 1028.567 168.75 1036.5 168.75 Z M1037.1 190.15 C1031.167 190.15 1026.533 191.833 1023.2 195.2 C1019.867 198.567 1017.667 203.417 1016.6 209.75 L1055.3 209.75 C1055.3 207.283 1054.983 204.883 1054.35 202.55 C1053.717 200.217 1052.683 198.133 1051.25 196.3 C1049.817 194.467 1047.933 192.983 1045.6 191.85 C1043.267 190.717 1040.433 190.15 1037.1 190.15 Z",
|
||||
] as const;
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Bot identity hues. A bot keeps its hue for life; a hue never means state.
|
||||
* The coordinator has no hue.
|
||||
*/
|
||||
export const BotHues = [
|
||||
{
|
||||
title: "Ember",
|
||||
value: "ember",
|
||||
},
|
||||
{
|
||||
title: "Amber",
|
||||
value: "amber",
|
||||
},
|
||||
{
|
||||
title: "Moss",
|
||||
value: "moss",
|
||||
},
|
||||
{
|
||||
title: "Teal",
|
||||
value: "teal",
|
||||
},
|
||||
{
|
||||
title: "Cobalt",
|
||||
value: "cobalt",
|
||||
},
|
||||
{
|
||||
title: "Violet",
|
||||
value: "violet",
|
||||
},
|
||||
{
|
||||
title: "Rose",
|
||||
value: "rose",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type BotHue = (typeof BotHues)[number]["value"];
|
||||
|
||||
export const BotKinds = [
|
||||
{
|
||||
title: "Worker",
|
||||
value: "worker",
|
||||
},
|
||||
{
|
||||
title: "Coordinator",
|
||||
value: "coordinator",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type BotKind = (typeof BotKinds)[number]["value"];
|
||||
|
||||
/**
|
||||
* A bot's face is its status. There are no badges or spinners.
|
||||
*/
|
||||
export const BotFaces = [
|
||||
{
|
||||
title: "Idle",
|
||||
value: "idle",
|
||||
},
|
||||
{
|
||||
title: "Working",
|
||||
value: "working",
|
||||
},
|
||||
{
|
||||
title: "Done",
|
||||
value: "done",
|
||||
},
|
||||
{
|
||||
title: "Asleep",
|
||||
value: "asleep",
|
||||
},
|
||||
{
|
||||
title: "Needs you",
|
||||
value: "needs-you",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type BotFace = (typeof BotFaces)[number]["value"];
|
||||
|
||||
export const BotMessageAuthors = [
|
||||
{
|
||||
title: "Bot",
|
||||
value: "bot",
|
||||
},
|
||||
{
|
||||
title: "User",
|
||||
value: "user",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type BotMessageAuthor = (typeof BotMessageAuthors)[number]["value"];
|
||||
|
||||
export const BotTaskStatuses = [
|
||||
{
|
||||
title: "Active",
|
||||
value: "active",
|
||||
},
|
||||
{
|
||||
title: "Done",
|
||||
value: "done",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type BotTaskStatus = (typeof BotTaskStatuses)[number]["value"];
|
||||
|
||||
export const BotTaskStepStates = [
|
||||
{
|
||||
title: "Done",
|
||||
value: "done",
|
||||
},
|
||||
{
|
||||
title: "Active",
|
||||
value: "active",
|
||||
},
|
||||
{
|
||||
title: "Pending",
|
||||
value: "pending",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type BotTaskStepState = (typeof BotTaskStepStates)[number]["value"];
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { BotHue } from "./bot-dict";
|
||||
|
||||
/**
|
||||
* Static Tailwind class names per hue, so the classes survive the build.
|
||||
*/
|
||||
export const BotHueFillClasses: Record<BotHue, string> = {
|
||||
ember: "fill-hue-ember",
|
||||
amber: "fill-hue-amber",
|
||||
moss: "fill-hue-moss",
|
||||
teal: "fill-hue-teal",
|
||||
cobalt: "fill-hue-cobalt",
|
||||
violet: "fill-hue-violet",
|
||||
rose: "fill-hue-rose",
|
||||
};
|
||||
|
||||
export const BotHueBgClasses: Record<BotHue, string> = {
|
||||
ember: "bg-hue-ember",
|
||||
amber: "bg-hue-amber",
|
||||
moss: "bg-hue-moss",
|
||||
teal: "bg-hue-teal",
|
||||
cobalt: "bg-hue-cobalt",
|
||||
violet: "bg-hue-violet",
|
||||
rose: "bg-hue-rose",
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
export const EmailStatuses = [
|
||||
{
|
||||
title: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
title: "Sent",
|
||||
value: "sent",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type EmailStatus = (typeof EmailStatuses)[number]["value"];
|
||||
|
||||
export const EmailAttachmentTypes = [
|
||||
{
|
||||
title: "Letter",
|
||||
value: "letter",
|
||||
},
|
||||
{
|
||||
title: "Invoice",
|
||||
value: "invoice",
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
value: "quote",
|
||||
},
|
||||
{
|
||||
title: "File",
|
||||
value: "file",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type EmailAttachmentType = (typeof EmailAttachmentTypes)[number]["value"];
|
||||
|
||||
export const InvoiceStatuses = [
|
||||
{
|
||||
title: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
title: "Issued",
|
||||
value: "issued",
|
||||
},
|
||||
{
|
||||
title: "Paid",
|
||||
value: "paid",
|
||||
},
|
||||
{
|
||||
title: "Cancelled",
|
||||
value: "cancelled",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type InvoiceStatus = (typeof InvoiceStatuses)[number]["value"];
|
||||
|
||||
export const QuoteStatuses = [
|
||||
{
|
||||
title: "Draft",
|
||||
value: "draft",
|
||||
},
|
||||
{
|
||||
title: "Sent",
|
||||
value: "sent",
|
||||
},
|
||||
{
|
||||
title: "Accepted",
|
||||
value: "accepted",
|
||||
},
|
||||
{
|
||||
title: "Declined",
|
||||
value: "declined",
|
||||
},
|
||||
{
|
||||
title: "Expired",
|
||||
value: "expired",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type QuoteStatus = (typeof QuoteStatuses)[number]["value"];
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import checkUserAccess from "./check-user-access";
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
|
||||
function makeUserTypes(...types: string[]): BUN_SQLITE_BSQLITE_USER_TYPES[] {
|
||||
function makeUserTypes(...types: string[]): BUN_SQLITE_COBBLE_USER_TYPES[] {
|
||||
return types.map((user_type) => ({
|
||||
user_id: 1,
|
||||
user_type,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Params = {
|
||||
includes?: UserType[];
|
||||
excludes?: UserType[];
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
export default function checkUserAccess({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
@@ -24,7 +24,7 @@ export default async function createSignupUser({
|
||||
username,
|
||||
}: Params) {
|
||||
const new_user_res = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "users",
|
||||
@@ -50,7 +50,7 @@ export default async function createSignupUser({
|
||||
}
|
||||
|
||||
const type_res = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
|
||||
@@ -10,7 +10,7 @@ type Params = {
|
||||
|
||||
export default async function deleteUserSSOCodes({ user_id }: Params) {
|
||||
return await BunSQLite.delete<
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "sso_login_codes",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
identifier: string;
|
||||
@@ -11,7 +11,7 @@ type Params = {
|
||||
export default async function findUserByIdentifier({ identifier }: Params) {
|
||||
return (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "users",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
@@ -24,7 +24,7 @@ export default async function generateUniqueUsername({ email }: Params) {
|
||||
|
||||
const existing = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "users",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { UserAuthReturn } from "@/src/types";
|
||||
@@ -16,7 +16,7 @@ export default async function grabUserAuthTypes({
|
||||
const user_types =
|
||||
(
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
BUN_SQLITE_COBBLE_USER_PASSWORDS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
|
||||
@@ -15,7 +15,7 @@ type Params = {
|
||||
export default async function grabUserPasswordHash({ user_id }: Params) {
|
||||
const record = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
BUN_SQLITE_COBBLE_USER_PASSWORDS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_passwords",
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
BunextAPIRouteHandlerObjectReturn,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function issueSSOLoginCode({
|
||||
const sso_code = randomInt(0, 1000000).toString().padStart(6, "0");
|
||||
|
||||
const record_sso = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
data: [
|
||||
|
||||
@@ -4,8 +4,8 @@ import { encrypt } from "@/src/utils/crypt";
|
||||
import grabUsers from "../db/users/grab-users";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import generateCSRF from "./gen-csrf";
|
||||
@@ -44,7 +44,7 @@ export default async function loginUser({
|
||||
|
||||
const target_user_types = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
@@ -65,7 +65,7 @@ export default async function loginUser({
|
||||
const profile_media = target_user.profile_media_id
|
||||
? (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
BUN_SQLITE_COBBLE_USER_PASSWORDS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import hashPassword from "./hash-password";
|
||||
@@ -21,7 +21,7 @@ export default async function setUserPassword({ user_id, password }: Params) {
|
||||
const password_hash = await hashPassword({ password });
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
|
||||
BUN_SQLITE_COBBLE_USER_PASSWORDS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_passwords",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
@@ -70,7 +70,7 @@ export default async function verifySSOCode({
|
||||
|
||||
const target_sso = (
|
||||
await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
|
||||
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "sso_login_codes",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOT_TASK_STEPS,
|
||||
BUN_SQLITE_COBBLE_BOT_TASKS,
|
||||
} from "@/db/types/db";
|
||||
import type { BotTaskWithSteps } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The bot's most recent active task and its steps, for the working-on panel.
|
||||
*/
|
||||
export default async function grabActiveBotTask({
|
||||
bot_id,
|
||||
}: Params): Promise<BotTaskWithSteps | null> {
|
||||
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
|
||||
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active' ORDER BY created_at DESC, id DESC LIMIT 1`,
|
||||
values: [bot_id],
|
||||
});
|
||||
|
||||
const task = task_res.singleRes || task_res.payload?.[0];
|
||||
|
||||
if (!task?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASK_STEPS>({
|
||||
sql: `SELECT * FROM bot_task_steps WHERE task_id = ? ORDER BY position ASC, id ASC`,
|
||||
values: [task.id],
|
||||
});
|
||||
|
||||
return { ...task, steps: steps_res.payload || [] };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest messages of a bot's conversation, oldest first.
|
||||
*/
|
||||
export default async function grabBotMessages({
|
||||
bot_id,
|
||||
limit = 100,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES[]> {
|
||||
let sql = ``;
|
||||
|
||||
sql += `SELECT * FROM (\n`;
|
||||
sql += ` SELECT * FROM bot_messages\n`;
|
||||
sql += ` WHERE bot_id = ?\n`;
|
||||
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
||||
sql += ` LIMIT ?\n`;
|
||||
sql += `) ORDER BY created_at ASC, id ASC\n`;
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||
sql,
|
||||
values: [bot_id, limit],
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace's crew for the rail: the coordinator pinned first, then
|
||||
* workers by latest activity. Each row carries its latest message.
|
||||
*/
|
||||
export default async function grabBots({
|
||||
user_id,
|
||||
}: Params): Promise<BotWithLastMessage[]> {
|
||||
let sql = ``;
|
||||
|
||||
sql += `SELECT\n`;
|
||||
sql += ` b.*,\n`;
|
||||
sql += ` lm.text AS last_message_text,\n`;
|
||||
sql += ` lm.created_at AS last_message_at\n`;
|
||||
sql += `FROM bots b\n`;
|
||||
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
|
||||
sql += ` SELECT id FROM bot_messages\n`;
|
||||
sql += ` WHERE bot_id = b.id\n`;
|
||||
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
||||
sql += ` LIMIT 1\n`;
|
||||
sql += `)\n`;
|
||||
sql += `WHERE b.user_id = ?\n`;
|
||||
sql += `ORDER BY\n`;
|
||||
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
|
||||
sql += ` COALESCE(lm.created_at, b.last_activity_at, b.created_at) DESC\n`;
|
||||
|
||||
const res = await BunSQLite.sql<BotWithLastMessage>({
|
||||
sql,
|
||||
values: [user_id],
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot only if it belongs to the given workspace owner.
|
||||
*/
|
||||
export default async function grabUserBot({
|
||||
bot_id,
|
||||
user_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
values: [bot_id, user_id],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends a user message to a bot's conversation and bumps its activity.
|
||||
* Callers must check the bot belongs to the session user first.
|
||||
*/
|
||||
export default async function insertUserMessage({
|
||||
bot_id,
|
||||
text,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "bot_messages",
|
||||
data: [{ bot_id, author: "user", text, needs_you: 0 }],
|
||||
});
|
||||
|
||||
const id = res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !id) {
|
||||
throw new Error(`Failed to save message: ${res.msg}`);
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots SET last_activity_at = ? WHERE id = ?`,
|
||||
values: [now, bot_id],
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
bot_id,
|
||||
author: "user",
|
||||
text,
|
||||
needs_you: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears a bot's unread flag when its owner opens the conversation.
|
||||
*/
|
||||
export default async function markBotRead({ bot_id, user_id }: Params) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots SET unread = 0 WHERE id = ? AND user_id = ? AND unread = 1`,
|
||||
values: [bot_id, user_id],
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { TableType } from "@/src/types";
|
||||
import uniqueStringArr from "@/src/utils/unique-string-arr";
|
||||
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
|
||||
@@ -15,11 +15,11 @@ type Params = {
|
||||
/**
|
||||
* Allowed fields from joins
|
||||
*/
|
||||
include_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
|
||||
include_fields?: (keyof BUN_SQLITE_COBBLE_ALL_TYPEDEFS)[];
|
||||
/**
|
||||
* Fields to exclude from joins
|
||||
*/
|
||||
exclude_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
|
||||
exclude_fields?: (keyof BUN_SQLITE_COBBLE_ALL_TYPEDEFS)[];
|
||||
};
|
||||
|
||||
export default function checkAllowedJoins({
|
||||
@@ -53,7 +53,7 @@ export default function checkAllowedJoins({
|
||||
for (let i = 0; i < all_joins.length; i++) {
|
||||
const join = all_joins[i];
|
||||
if (!join) continue;
|
||||
const join_field = join.field as keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS;
|
||||
const join_field = join.field as keyof BUN_SQLITE_COBBLE_ALL_TYPEDEFS;
|
||||
if (!include_fields.includes(join_field)) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS } from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export type EmailAttachmentRecord = BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS & {
|
||||
letter_title?: string;
|
||||
invoice_number?: string;
|
||||
invoice_title?: string;
|
||||
quote_number?: string;
|
||||
quote_title?: string;
|
||||
media_write_path?: string;
|
||||
media_mime_type?: string;
|
||||
};
|
||||
|
||||
export default async function grabEmailAttachments({
|
||||
email_id,
|
||||
}: {
|
||||
email_id: number | string;
|
||||
}): Promise<APIResponseObject<EmailAttachmentRecord>> {
|
||||
return await BunSQLite.sql<EmailAttachmentRecord>({
|
||||
sql: `
|
||||
SELECT
|
||||
ea.*,
|
||||
l.title AS letter_title,
|
||||
i.invoice_number,
|
||||
i.title AS invoice_title,
|
||||
q.quote_number,
|
||||
q.title AS quote_title,
|
||||
m.media_write_path,
|
||||
m.mime_type AS media_mime_type
|
||||
FROM email_attachments ea
|
||||
LEFT JOIN letters l ON l.id = ea.letter_id
|
||||
LEFT JOIN invoices i ON i.id = ea.invoice_id
|
||||
LEFT JOIN quotes q ON q.id = ea.quote_id
|
||||
LEFT JOIN media m ON m.id = ea.media_id
|
||||
WHERE ea.email_id = ?
|
||||
ORDER BY ea.id ASC
|
||||
`,
|
||||
values: [email_id],
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabEmail({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_EMAILS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_EMAILS>({
|
||||
table: "emails",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabLetter({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_LETTERS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_LETTERS>({
|
||||
table: "letters",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import grabNextDocumentSequence, {
|
||||
type DocumentNumberSqlRunner,
|
||||
} from "./grab-next-document-sequence";
|
||||
|
||||
let db: Database;
|
||||
let run_sql: DocumentNumberSqlRunner;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
db.run("CREATE TABLE invoices (invoice_number TEXT UNIQUE)");
|
||||
|
||||
run_sql = async ({ sql, values }) => {
|
||||
const row = db.query(sql).get(...values) as
|
||||
| { max_seq: number | null }
|
||||
| null;
|
||||
|
||||
return { singleRes: row };
|
||||
};
|
||||
});
|
||||
|
||||
function insertNumbers(numbers: string[]) {
|
||||
const insert = db.prepare("INSERT INTO invoices (invoice_number) VALUES (?)");
|
||||
|
||||
for (const number of numbers) {
|
||||
insert.run(number);
|
||||
}
|
||||
}
|
||||
|
||||
describe("grabNextDocumentSequence", () => {
|
||||
test("starts at 1 for an empty table", async () => {
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(1);
|
||||
});
|
||||
|
||||
test("returns max + 1 for existing numbers", async () => {
|
||||
insertNumbers([
|
||||
"BSQLITE-202610-001",
|
||||
"BSQLITE-202610-002",
|
||||
"BSQLITE-202610-003",
|
||||
]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(4);
|
||||
});
|
||||
|
||||
test("does not reuse a number after a deletion", async () => {
|
||||
insertNumbers(["BSQLITE-202610-001", "BSQLITE-202610-003"]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(4);
|
||||
});
|
||||
|
||||
test("ignores numbers from other prefixes", async () => {
|
||||
insertNumbers([
|
||||
"BSQLITE-202609-099",
|
||||
"BSQLITEQ-202610-050",
|
||||
"BSQLITE-202610-002",
|
||||
]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(3);
|
||||
});
|
||||
|
||||
test("handles sequences beyond three digits", async () => {
|
||||
insertNumbers(["BSQLITE-202610-009", "BSQLITE-202610-010"]);
|
||||
|
||||
const next = await grabNextDocumentSequence({
|
||||
table: "invoices",
|
||||
column: "invoice_number",
|
||||
prefix: "BSQLITE-202610",
|
||||
run_sql,
|
||||
});
|
||||
|
||||
expect(next).toBe(11);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type DocumentNumberTarget = {
|
||||
table: "invoices" | "quotes";
|
||||
column: "invoice_number" | "quote_number";
|
||||
};
|
||||
|
||||
export type DocumentNumberSqlRunner = (args: {
|
||||
sql: string;
|
||||
values: (string | number)[];
|
||||
}) => Promise<{ singleRes?: { max_seq?: number | string | null } | null }>;
|
||||
|
||||
type Params = DocumentNumberTarget & {
|
||||
prefix: string;
|
||||
/**
|
||||
* Injectable query runner, used by tests to target an in-memory DB.
|
||||
*/
|
||||
run_sql?: DocumentNumberSqlRunner;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Grab the next document sequence.
|
||||
* Reads the highest existing numeric suffix for the prefix instead of
|
||||
* counting rows, so deleting a document can never hand out a number that
|
||||
* already exists.
|
||||
*/
|
||||
export default async function grabNextDocumentSequence({
|
||||
table,
|
||||
column,
|
||||
prefix,
|
||||
run_sql,
|
||||
}: Params): Promise<number> {
|
||||
const sql = `SELECT MAX(CAST(SUBSTR(${column}, ?) AS INTEGER)) AS max_seq FROM ${table} WHERE ${column} LIKE ?`;
|
||||
const values = [prefix.length + 2, `${prefix}-%`];
|
||||
|
||||
const res = run_sql
|
||||
? await run_sql({ sql, values })
|
||||
: await BunSQLite.sql({ sql, values });
|
||||
|
||||
return Number(res.singleRes?.max_seq || 0) + 1;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_INVOICE_ITEMS,
|
||||
BUN_SQLITE_BSQLITE_INVOICE_QUOTES,
|
||||
BUN_SQLITE_BSQLITE_INVOICES,
|
||||
} from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabInvoice({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICES>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICES>({
|
||||
table: "invoices",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function grabInvoiceItems({
|
||||
invoice_id,
|
||||
}: {
|
||||
invoice_id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>({
|
||||
table: "invoice_items",
|
||||
query: {
|
||||
query: {
|
||||
invoice_id: {
|
||||
value: Number(invoice_id),
|
||||
},
|
||||
},
|
||||
order: [
|
||||
{ field: "sort_order", strategy: "ASC" },
|
||||
{ field: "id", strategy: "ASC" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type InvoiceQuoteLinkRecord = BUN_SQLITE_BSQLITE_INVOICE_QUOTES & {
|
||||
quote_number?: string;
|
||||
quote_title?: string;
|
||||
};
|
||||
|
||||
export async function grabInvoiceQuoteLinks({
|
||||
invoice_id,
|
||||
}: {
|
||||
invoice_id: number | string;
|
||||
}): Promise<APIResponseObject<InvoiceQuoteLinkRecord>> {
|
||||
return await BunSQLite.sql<InvoiceQuoteLinkRecord>({
|
||||
sql: `
|
||||
SELECT
|
||||
iq.*,
|
||||
q.quote_number,
|
||||
q.title AS quote_title
|
||||
FROM invoice_quotes iq
|
||||
LEFT JOIN quotes q ON q.id = iq.quote_id
|
||||
WHERE iq.invoice_id = ?
|
||||
ORDER BY iq.id ASC
|
||||
`,
|
||||
values: [invoice_id],
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import _ from "lodash";
|
||||
import grabUsers from "../users/grab-users";
|
||||
|
||||
type Params = {
|
||||
media_res: APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>;
|
||||
media_res: APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_JOIN>;
|
||||
};
|
||||
|
||||
export default async function appendUsersToMedia({
|
||||
media_res,
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_JOIN>> {
|
||||
try {
|
||||
let new_media_res = _.cloneDeep(media_res);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import _ from "lodash";
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
|
||||
import type { MediaParadigmType, TableType, User } from "@/src/types";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
@@ -55,7 +55,7 @@ export default async function deleteMedia(params: Params) {
|
||||
const media_id_to_delete = media_ids_to_del[i];
|
||||
|
||||
const target_media = (
|
||||
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
|
||||
await BunSQLite.select<BUN_SQLITE_COBBLE_MEDIA>({
|
||||
table: "media",
|
||||
targetId: media_id_to_delete,
|
||||
})
|
||||
@@ -77,7 +77,7 @@ export default async function deleteMedia(params: Params) {
|
||||
const media_id = media_to_delete_array[i];
|
||||
|
||||
const target_media = (
|
||||
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
|
||||
await BunSQLite.select<BUN_SQLITE_COBBLE_MEDIA>({
|
||||
table: "media",
|
||||
targetId: media_id,
|
||||
})
|
||||
@@ -101,7 +101,7 @@ export default async function deleteMedia(params: Params) {
|
||||
);
|
||||
}
|
||||
|
||||
await BunSQLite.delete<BUN_SQLITE_BSQLITE_MEDIA, TableType>({
|
||||
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
|
||||
table: "media",
|
||||
targetId: target_media.id,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA_PARADIGMS } from "@/db/types/db";
|
||||
import type { MediaParadigmType, TableType } from "@/src/types";
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN } from "@/src/types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA_PARADIGMS_JOIN } from "@/src/types/sql-joins";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
@@ -13,10 +13,10 @@ type Params = {
|
||||
export default async function grabMediaFromParadigm({
|
||||
media_paradigm,
|
||||
user_id,
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN>> {
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_PARADIGMS_JOIN>> {
|
||||
try {
|
||||
const media_paradigms_res = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
|
||||
TableType
|
||||
>({
|
||||
table: "media_paradigms",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ApiReqParams, User } from "@/src/types";
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA_JOIN } from "@/src/types/sql-joins";
|
||||
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import type { SQLInsertGenValueType } from "@moduletrace/bun-sqlite";
|
||||
import _ from "lodash";
|
||||
@@ -14,7 +14,7 @@ type Params = {
|
||||
|
||||
export default async function grabMedia(
|
||||
params?: Params,
|
||||
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
|
||||
): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_JOIN>> {
|
||||
const { query, user } = params || {};
|
||||
|
||||
try {
|
||||
@@ -72,7 +72,7 @@ export default async function grabMedia(
|
||||
|
||||
// 4. Parameterized Search Terms
|
||||
if (query?.search_terms?.length) {
|
||||
const fields_to_search: (keyof BUN_SQLITE_BSQLITE_MEDIA)[] = [
|
||||
const fields_to_search: (keyof BUN_SQLITE_COBBLE_MEDIA)[] = [
|
||||
"title",
|
||||
"description",
|
||||
"summary",
|
||||
@@ -109,7 +109,7 @@ export default async function grabMedia(
|
||||
const final_sql = `${sql}\n${sql_limit}`;
|
||||
const final_values = [...values, ...limitValues];
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_MEDIA_JOIN>({
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA_JOIN>({
|
||||
sql: final_sql,
|
||||
values: final_values,
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_QUOTE_ITEMS,
|
||||
BUN_SQLITE_BSQLITE_QUOTES,
|
||||
} from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
export default async function grabQuote({
|
||||
id,
|
||||
}: {
|
||||
id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTES>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTES>({
|
||||
table: "quotes",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function grabQuoteItems({
|
||||
quote_id,
|
||||
}: {
|
||||
quote_id: number | string;
|
||||
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>> {
|
||||
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>({
|
||||
table: "quote_items",
|
||||
query: {
|
||||
query: {
|
||||
quote_id: {
|
||||
value: Number(quote_id),
|
||||
},
|
||||
},
|
||||
order: [
|
||||
{ field: "sort_order", strategy: "ASC" },
|
||||
{ field: "id", strategy: "ASC" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
@@ -13,9 +13,9 @@ type Params = {
|
||||
*/
|
||||
export default async function grabUserTypes({
|
||||
user_id,
|
||||
}: Params): Promise<BUN_SQLITE_BSQLITE_USER_TYPES[]> {
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_USER_TYPES[]> {
|
||||
const res = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_USER_TYPES,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "user_types",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ApiReqParams, SQLInsertGenValueType } from "@/src/types";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import _ from "lodash";
|
||||
@@ -17,7 +17,7 @@ type Params = {
|
||||
|
||||
export default async function grabUsers(
|
||||
params?: Params,
|
||||
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_USERS_JOIN>> {
|
||||
): Promise<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> {
|
||||
const { query } = params || {};
|
||||
|
||||
try {
|
||||
@@ -123,7 +123,7 @@ export default async function grabUsers(
|
||||
sql_limit += `OFFSET ${offset}\n`;
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_USERS_JOIN>({
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_USERS_JOIN>({
|
||||
sql: `${sql}\n${sql_limit}`,
|
||||
values,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
BUN_SQLITE_BSQLITE_USERS,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
@@ -26,12 +26,12 @@ import deleteMedia from "../../media/delete-media";
|
||||
type Params = {
|
||||
media_paradigms?: MediaParadigm[];
|
||||
media_type?: MediaType;
|
||||
user: BUN_SQLITE_BSQLITE_USERS | User;
|
||||
user: BUN_SQLITE_COBBLE_USERS | User;
|
||||
data?: MediaDataType;
|
||||
is_primary?: boolean;
|
||||
update_on_duplicate?: boolean;
|
||||
extract_media_text_content?: boolean;
|
||||
media?: BUN_SQLITE_BSQLITE_MEDIA;
|
||||
media?: BUN_SQLITE_COBBLE_MEDIA;
|
||||
media_name?: string;
|
||||
media_mime_type?: string;
|
||||
is_private?: boolean;
|
||||
@@ -59,7 +59,7 @@ export default async function uploadAndRecordMedia({
|
||||
thumbnail_width,
|
||||
existing_media_id,
|
||||
media_text_content: passed_media_text_content,
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA>> {
|
||||
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA>> {
|
||||
const now = Date.now();
|
||||
let target_file_name = `${media_name || now}`;
|
||||
let target_file_thumbnail_name = `${media_name || now}-thumb`;
|
||||
@@ -207,7 +207,7 @@ export default async function uploadAndRecordMedia({
|
||||
|
||||
if (is_primary) {
|
||||
const existing_user_primary_media = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
TableType
|
||||
>({
|
||||
table: "media",
|
||||
@@ -227,7 +227,7 @@ export default async function uploadAndRecordMedia({
|
||||
}
|
||||
}
|
||||
|
||||
const media_data: BUN_SQLITE_BSQLITE_MEDIA = {
|
||||
const media_data: BUN_SQLITE_COBBLE_MEDIA = {
|
||||
...media,
|
||||
user_id: Number(user.id),
|
||||
is_primary: is_primary ? 1 : 0,
|
||||
@@ -239,11 +239,11 @@ export default async function uploadAndRecordMedia({
|
||||
text_content: media_text_content,
|
||||
};
|
||||
|
||||
let media_record: BUN_SQLITE_BSQLITE_MEDIA | undefined = undefined;
|
||||
let media_record: BUN_SQLITE_COBBLE_MEDIA | undefined = undefined;
|
||||
|
||||
if (existing_media_id) {
|
||||
const update_media_record = await BunSQLite.update<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
TableType
|
||||
>({
|
||||
data: media_data,
|
||||
@@ -254,7 +254,7 @@ export default async function uploadAndRecordMedia({
|
||||
media_record = update_media_record.singleRes || undefined;
|
||||
} else {
|
||||
const new_media_record = await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
TableType
|
||||
>({
|
||||
data: [media_data],
|
||||
@@ -271,7 +271,7 @@ export default async function uploadAndRecordMedia({
|
||||
|
||||
if (media_paradigms?.[0] && media_record?.id) {
|
||||
const delete_existing_paradigms = await BunSQLite.delete<
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media_paradigms",
|
||||
@@ -281,7 +281,7 @@ export default async function uploadAndRecordMedia({
|
||||
});
|
||||
|
||||
await BunSQLite.insert<
|
||||
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
|
||||
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
data: media_paradigms.map((mp) => ({
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import { formatInvoiceDate } from "@/src/functions/backend/pdf/letter-date";
|
||||
import { CompanyLetterheadDefaults } from "@/src/dict/company-dict";
|
||||
import { formatNaira } from "@/src/utils/money";
|
||||
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
|
||||
|
||||
const letter_divider = (
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
type InvoiceEmailContentProps = {
|
||||
invoice: {
|
||||
invoice_number?: string;
|
||||
client_name?: string;
|
||||
issue_date?: number | "";
|
||||
due_date?: number | "";
|
||||
status?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
description?: string;
|
||||
quantity?: number | string | "";
|
||||
unit_price_kobo?: number | string | "";
|
||||
}[];
|
||||
};
|
||||
|
||||
type QuoteEmailContentProps = {
|
||||
quote: {
|
||||
quote_number?: string;
|
||||
client_name?: string;
|
||||
issue_date?: number | "";
|
||||
valid_until?: number | "";
|
||||
status?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
description?: string;
|
||||
quantity?: number | string | "";
|
||||
unit_price_kobo?: number | string | "";
|
||||
}[];
|
||||
};
|
||||
|
||||
export function InvoiceEmailContent({
|
||||
invoice,
|
||||
items,
|
||||
}: InvoiceEmailContentProps) {
|
||||
const total_kobo = items.reduce((acc, item) => {
|
||||
return (
|
||||
acc +
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: ThemeColors.ink,
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name}
|
||||
</div>
|
||||
{letter_divider}
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
margin: "0 0 6px",
|
||||
}}
|
||||
>
|
||||
Invoice {invoice.invoice_number || ""}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Hello {invoice.client_name || "there"},
|
||||
<br />
|
||||
Please find attached invoice {invoice.invoice_number || ""} for
|
||||
your records.
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
|
||||
<table
|
||||
width="100%"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ fontSize: "13px", borderCollapse: "collapse" }}
|
||||
>
|
||||
<tr
|
||||
style={{
|
||||
background: ThemeColors.ink,
|
||||
color: ThemeColors.surface,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "8px 10px" }}>Description</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Qty
|
||||
</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Amount
|
||||
</td>
|
||||
</tr>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={
|
||||
index % 2 == 0
|
||||
? { background: ThemeColors.faint }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td style={{ padding: "7px 10px" }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{Number(item.quantity) || 0}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{formatNaira(
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price_kobo) || 0),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Total (NGN)
|
||||
</td>
|
||||
<td
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatNaira(total_kobo)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Invoice {invoice.invoice_number || ""} was issued on{" "}
|
||||
{formatInvoiceDate(invoice.issue_date || Date.now())}
|
||||
{invoice.due_date
|
||||
? ` and is due by ${formatInvoiceDate(invoice.due_date)}`
|
||||
: ""}
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
Thank you for your business.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: ThemeColors.muted,
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
paddingTop: "12px",
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name} -{" "}
|
||||
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
|
||||
{CompanyLetterheadDefaults.phone} |{" "}
|
||||
{CompanyLetterheadDefaults.email} |{" "}
|
||||
{CompanyLetterheadDefaults.website}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function QuoteEmailContent({ quote, items }: QuoteEmailContentProps) {
|
||||
const total_kobo = items.reduce((acc, item) => {
|
||||
return (
|
||||
acc +
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: ThemeColors.ink,
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name}
|
||||
</div>
|
||||
{letter_divider}
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
margin: "0 0 6px",
|
||||
}}
|
||||
>
|
||||
Quotation {quote.quote_number || ""}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Hello {quote.client_name || "there"},
|
||||
<br />
|
||||
Please find attached quotation {quote.quote_number || ""} for
|
||||
our proposed works.
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
|
||||
<table
|
||||
width="100%"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ fontSize: "13px", borderCollapse: "collapse" }}
|
||||
>
|
||||
<tr
|
||||
style={{
|
||||
background: ThemeColors.ink,
|
||||
color: ThemeColors.surface,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "8px 10px" }}>Description</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Qty
|
||||
</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Amount
|
||||
</td>
|
||||
</tr>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={
|
||||
index % 2 == 0
|
||||
? { background: ThemeColors.faint }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td style={{ padding: "7px 10px" }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{Number(item.quantity) || 0}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{formatNaira(
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price_kobo) || 0),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Total (NGN)
|
||||
</td>
|
||||
<td
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatNaira(total_kobo)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Quotation {quote.quote_number || ""} was issued on{" "}
|
||||
{formatInvoiceDate(quote.issue_date || Date.now())}
|
||||
{quote.valid_until
|
||||
? ` and is valid until ${formatInvoiceDate(quote.valid_until)}`
|
||||
: ""}
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
We look forward to working with you.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: ThemeColors.muted,
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
paddingTop: "12px",
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name} -{" "}
|
||||
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
|
||||
{CompanyLetterheadDefaults.phone} |{" "}
|
||||
{CompanyLetterheadDefaults.email} |{" "}
|
||||
{CompanyLetterheadDefaults.website}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
|
||||
type Params = {
|
||||
media?: BUN_SQLITE_BSQLITE_MEDIA;
|
||||
user?: BUN_SQLITE_BSQLITE_USERS_JOIN;
|
||||
media?: BUN_SQLITE_COBBLE_MEDIA;
|
||||
user?: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
};
|
||||
|
||||
export default function grabMediaURL({ media: passed_media, user }: Params) {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import htmlToTextBlocks from "./html-blocks";
|
||||
|
||||
test("parses paragraphs and inline styles", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<p>Hello <strong>world</strong> and <em>friends</em>.</p><p>Second <u>underlined</u> paragraph.</p>`,
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]!.type).toBe("paragraph");
|
||||
expect((blocks[0] as any).segments).toEqual([
|
||||
{ text: "Hello " },
|
||||
{ text: "world", bold: true },
|
||||
{ text: " and " },
|
||||
{ text: "friends", italic: true },
|
||||
{ text: "." },
|
||||
]);
|
||||
expect((blocks[1] as any).segments).toEqual([
|
||||
{ text: "Second " },
|
||||
{ text: "underlined", underline: true },
|
||||
{ text: " paragraph." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses lists with ordering", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<ul><li>One</li><li>Two</li></ul><ol><li>First</li></ol>`,
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(3);
|
||||
expect(blocks[0]!).toEqual({
|
||||
type: "list-item",
|
||||
ordered: false,
|
||||
index: 0,
|
||||
segments: [{ text: "One" }],
|
||||
});
|
||||
expect(blocks[1]!).toEqual({
|
||||
type: "list-item",
|
||||
ordered: false,
|
||||
index: 1,
|
||||
segments: [{ text: "Two" }],
|
||||
});
|
||||
expect(blocks[2]!).toEqual({
|
||||
type: "list-item",
|
||||
ordered: true,
|
||||
index: 0,
|
||||
segments: [{ text: "First" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("parses headings, blockquote, hr and br", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<h2>Heading</h2><blockquote>Quoted</blockquote><hr><p>Line one<br>Line two</p>`,
|
||||
);
|
||||
|
||||
expect(blocks[0]!.type).toBe("heading");
|
||||
expect((blocks[0] as any).level).toBe(2);
|
||||
expect(blocks[1]!.type).toBe("blockquote");
|
||||
expect(blocks[2]!).toEqual({ type: "divider" });
|
||||
// <br> splits into separate paragraphs
|
||||
expect(blocks).toHaveLength(5);
|
||||
expect((blocks[3] as any).segments.map((s: any) => s.text).join("")).toBe(
|
||||
"Line one",
|
||||
);
|
||||
expect((blocks[4] as any).segments.map((s: any) => s.text).join("")).toBe(
|
||||
"Line two",
|
||||
);
|
||||
});
|
||||
|
||||
test("decodes HTML entities", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<p>Fee & charges — £500 > 400</p>`,
|
||||
);
|
||||
|
||||
expect((blocks[0] as any).segments[0].text).toBe(
|
||||
"Fee & charges \u2014 \u00a3500 > 400",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses img alt text and ignores unknown tags", () => {
|
||||
const blocks = htmlToTextBlocks(
|
||||
`<p>See <img src="x.png" alt="the chart" /> below.</p><svg><circle></circle></svg>`,
|
||||
);
|
||||
|
||||
expect((blocks[0] as any).segments.map((s: any) => s.text).join("")).toBe(
|
||||
"See the chart below.",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty for empty input", () => {
|
||||
expect(htmlToTextBlocks(undefined)).toEqual([]);
|
||||
expect(htmlToTextBlocks("")).toEqual([]);
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
export type TextSegment = {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
};
|
||||
|
||||
export type TextBlock =
|
||||
| { type: "paragraph"; segments: TextSegment[] }
|
||||
| { type: "heading"; level: number; segments: TextSegment[] }
|
||||
| { type: "list-item"; ordered: boolean; index: number; segments: TextSegment[] }
|
||||
| { type: "blockquote"; segments: TextSegment[] }
|
||||
| { type: "divider" };
|
||||
|
||||
const named_entities: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: " ",
|
||||
ensp: " ",
|
||||
emsp: " ",
|
||||
ndash: "\u2013",
|
||||
mdash: "\u2014",
|
||||
hellip: "\u2026",
|
||||
lsquo: "\u2018",
|
||||
rsquo: "\u2019",
|
||||
ldquo: "\u201c",
|
||||
rdquo: "\u201d",
|
||||
bull: "\u2022",
|
||||
middot: "\u00b7",
|
||||
times: "\u00d7",
|
||||
copy: "\u00a9",
|
||||
reg: "\u00ae",
|
||||
trade: "\u2122",
|
||||
deg: "\u00b0",
|
||||
sect: "\u00a7",
|
||||
para: "\u00b6",
|
||||
euro: "\u20ac",
|
||||
pound: "\u00a3",
|
||||
minus: "\u2212",
|
||||
radic: "\u221a",
|
||||
frac12: "\u00bd",
|
||||
frac14: "\u00bc",
|
||||
frac34: "\u00be",
|
||||
};
|
||||
|
||||
function decodeEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
|
||||
String.fromCodePoint(parseInt(hex, 16)),
|
||||
)
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
||||
.replace(
|
||||
/&([a-zA-Z]+);/g,
|
||||
(match, name: string) => named_entities[name.toLowerCase()] || match,
|
||||
);
|
||||
}
|
||||
|
||||
const INLINE_STYLE_OPEN = ["b", "strong", "i", "em", "u", "ins"];
|
||||
const INLINE_STYLE_CLOSE = ["b", "strong", "i", "em", "u", "ins"];
|
||||
const HEADING_LEVELS: Record<string, number> = {
|
||||
h1: 1,
|
||||
h2: 2,
|
||||
h3: 3,
|
||||
h4: 4,
|
||||
h5: 5,
|
||||
h6: 6,
|
||||
};
|
||||
|
||||
/**
|
||||
* # Convert an HTML fragment (TinyMCE output) into simple text blocks
|
||||
* suitable for rendering in a PDF. Supports: p, div, br, h1-h6, ul/ol/li,
|
||||
* blockquote, hr, b/strong, i/em, u/ins, a, span, img (alt text) and
|
||||
* basic table cells (degraded to inline text separated by spaces).
|
||||
*/
|
||||
export default function htmlToTextBlocks(html?: string): TextBlock[] {
|
||||
const source = html || "";
|
||||
const blocks: TextBlock[] = [];
|
||||
|
||||
let pending_text = "";
|
||||
const style = { bold: false, italic: false, underline: false };
|
||||
let current: TextBlock | undefined;
|
||||
const list_stack: { ordered: boolean; index: number }[] = [];
|
||||
|
||||
const appendText = (raw: string) => {
|
||||
if (!raw) return;
|
||||
const decoded = decodeEntities(raw);
|
||||
if (!decoded) return;
|
||||
pending_text += decoded;
|
||||
};
|
||||
|
||||
const flushText = () => {
|
||||
if (!pending_text) return;
|
||||
if (!current) {
|
||||
current = { type: "paragraph", segments: [] };
|
||||
} else if (current.type == "divider") {
|
||||
current = { type: "paragraph", segments: [] };
|
||||
}
|
||||
current.segments.push({
|
||||
text: pending_text,
|
||||
...(style.bold ? { bold: true } : {}),
|
||||
...(style.italic ? { italic: true } : {}),
|
||||
...(style.underline ? { underline: true } : {}),
|
||||
});
|
||||
pending_text = "";
|
||||
};
|
||||
|
||||
const flushBlock = () => {
|
||||
flushText();
|
||||
if (current) {
|
||||
blocks.push(current);
|
||||
current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startBlock = (block: TextBlock) => {
|
||||
flushBlock();
|
||||
current = block;
|
||||
};
|
||||
|
||||
const pattern = /<(\/?)([a-zA-Z0-9]+)([^>]*)>/g;
|
||||
let last = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
appendText(source.slice(last, match.index));
|
||||
last = match.index + (match[0] || "").length;
|
||||
|
||||
const closing = Boolean(match[1]);
|
||||
const tag = (match[2] || "").toLowerCase();
|
||||
const attrs = match[3] || "";
|
||||
|
||||
if (INLINE_STYLE_OPEN.includes(tag) && !closing) {
|
||||
if (["b", "strong"].includes(tag)) {
|
||||
flushText();
|
||||
style.bold = true;
|
||||
}
|
||||
if (["i", "em"].includes(tag)) {
|
||||
flushText();
|
||||
style.italic = true;
|
||||
}
|
||||
if (["u", "ins"].includes(tag)) {
|
||||
flushText();
|
||||
style.underline = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (INLINE_STYLE_CLOSE.includes(tag) && closing) {
|
||||
if (["b", "strong"].includes(tag)) {
|
||||
flushText();
|
||||
style.bold = false;
|
||||
}
|
||||
if (["i", "em"].includes(tag)) {
|
||||
flushText();
|
||||
style.italic = false;
|
||||
}
|
||||
if (["u", "ins"].includes(tag)) {
|
||||
flushText();
|
||||
style.underline = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["p", "div"].includes(tag)) {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
startBlock({ type: "paragraph", segments: [] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HEADING_LEVELS[tag]) {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
startBlock({
|
||||
type: "heading",
|
||||
level: HEADING_LEVELS[tag],
|
||||
segments: [],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "br") {
|
||||
flushBlock();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "hr") {
|
||||
flushBlock();
|
||||
blocks.push({ type: "divider" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["ul", "ol"].includes(tag)) {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
list_stack.pop();
|
||||
} else {
|
||||
flushBlock();
|
||||
list_stack.push({ ordered: tag == "ol", index: 0 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "li") {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
const list = list_stack[list_stack.length - 1] || {
|
||||
ordered: false,
|
||||
index: 0,
|
||||
};
|
||||
startBlock({
|
||||
type: "list-item",
|
||||
ordered: list.ordered,
|
||||
index: list.index,
|
||||
segments: [],
|
||||
});
|
||||
list.index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "blockquote") {
|
||||
if (closing) {
|
||||
flushBlock();
|
||||
} else {
|
||||
startBlock({ type: "blockquote", segments: [] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag == "img" && !closing) {
|
||||
const alt = attrs.match(/alt\s*=\s*["']([^"']*)["']/i);
|
||||
if (alt?.[1]) appendText(` ${alt[1]} `);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["td", "th", "p", "div", "li"].includes(tag) && !closing) {
|
||||
appendText(" ");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (["table", "thead", "tbody", "tr"].includes(tag)) {
|
||||
if (!closing && ["tr"].includes(tag)) {
|
||||
// no-op, cells add their own spacing
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
appendText(source.slice(last));
|
||||
flushBlock();
|
||||
|
||||
return blocks;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
function ordinal(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 13) {
|
||||
return "th";
|
||||
}
|
||||
switch (n % 10) {
|
||||
case 1:
|
||||
return "st";
|
||||
case 2:
|
||||
return "nd";
|
||||
case 3:
|
||||
return "rd";
|
||||
default:
|
||||
return "th";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* # Format a timestamp as a letter date, e.g. "22nd September, 2026".
|
||||
*/
|
||||
export function formatLetterDate(timestamp: number): string {
|
||||
const date = dayjs(timestamp);
|
||||
return `${date.date()}${ordinal(date.date())} ${date.format("MMMM, YYYY")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* # Format a timestamp as a short invoice date, e.g. "22 Sep 2026".
|
||||
*/
|
||||
export function formatInvoiceDate(
|
||||
timestamp?: number | string | "",
|
||||
): string {
|
||||
if (!timestamp) {
|
||||
return "\u2014";
|
||||
}
|
||||
return dayjs(Number(timestamp)).format("DD MMM YYYY");
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import {
|
||||
CompanyLetterheadDefaults,
|
||||
type CompanyLetterhead,
|
||||
} from "@/src/dict/company-dict";
|
||||
import {
|
||||
PdfColors,
|
||||
PdfMargin,
|
||||
PdfPageHeight,
|
||||
PdfPageWidth,
|
||||
drawBrandFooterOnAllPages,
|
||||
drawBrandHeader,
|
||||
drawTextAt,
|
||||
embedBrandLogo,
|
||||
embedPdfFonts,
|
||||
type PdfFonts,
|
||||
} from "./pdf-helpers";
|
||||
import htmlToTextBlocks from "./html-blocks";
|
||||
import { formatLetterDate } from "./letter-date";
|
||||
import {
|
||||
ensureSpace,
|
||||
renderBlocks,
|
||||
type RenderContext,
|
||||
} from "./render-blocks";
|
||||
|
||||
type TemplateOverview = {
|
||||
title?: string;
|
||||
to_name?: string;
|
||||
to_address?: string;
|
||||
from_name?: string;
|
||||
from_address?: string;
|
||||
salutation?: string;
|
||||
body_html?: string;
|
||||
closing?: string;
|
||||
signature_name?: string;
|
||||
signature_title?: string;
|
||||
};
|
||||
|
||||
export type LetterPdfParams = {
|
||||
template: TemplateOverview;
|
||||
/** Overrides the template's default recipient at send-time. */
|
||||
to_name?: string;
|
||||
to_address?: string;
|
||||
/** Unix timestamp in ms. Defaults to now. */
|
||||
letter_date?: number;
|
||||
company?: CompanyLetterhead;
|
||||
};
|
||||
|
||||
const BODY_SIZE = 11;
|
||||
const BODY_LINE_GAP = 15.5;
|
||||
|
||||
export default async function generateLetterPdf({
|
||||
template,
|
||||
to_name,
|
||||
to_address,
|
||||
letter_date,
|
||||
company,
|
||||
}: LetterPdfParams): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const fonts = await embedPdfFonts(pdfDoc);
|
||||
const logo = await embedBrandLogo(pdfDoc);
|
||||
const company_block = {
|
||||
...CompanyLetterheadDefaults,
|
||||
...(company || {}),
|
||||
};
|
||||
|
||||
let page = pdfDoc.addPage([PdfPageWidth, PdfPageHeight]);
|
||||
const ctx: RenderContext = {
|
||||
pdfDoc,
|
||||
page,
|
||||
fonts,
|
||||
y: drawBrandHeader({
|
||||
page,
|
||||
fonts,
|
||||
options: { logo, ...company_block },
|
||||
}),
|
||||
};
|
||||
|
||||
const from_name = template.from_name?.trim();
|
||||
const from_address = template.from_address?.trim();
|
||||
|
||||
if (from_name || from_address) {
|
||||
ensureSpace(ctx, 80);
|
||||
|
||||
ctx.y += 6;
|
||||
drawTextAt(ctx.page, "From:", {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 9,
|
||||
font: fonts.bold,
|
||||
color: PdfColors.darkGray,
|
||||
});
|
||||
ctx.y += 14;
|
||||
|
||||
if (from_name) {
|
||||
drawTextAt(ctx.page, from_name, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 10.5,
|
||||
font: fonts.bold,
|
||||
color: PdfColors.black,
|
||||
});
|
||||
ctx.y += 13.5;
|
||||
}
|
||||
|
||||
if (from_address) {
|
||||
for (const line of from_address.split(/\r?\n/)) {
|
||||
drawTextAt(ctx.page, line, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 10.5,
|
||||
font: fonts.regular,
|
||||
color: PdfColors.black,
|
||||
});
|
||||
ctx.y += 13;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.y += 10;
|
||||
}
|
||||
|
||||
const date_text = formatLetterDate(letter_date || Date.now());
|
||||
|
||||
drawTextAt(ctx.page, date_text, {
|
||||
x: PdfPageWidth - PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 10.5,
|
||||
font: fonts.regular,
|
||||
color: PdfColors.black,
|
||||
align: "right",
|
||||
});
|
||||
ctx.y += 24;
|
||||
|
||||
const final_to_name = (to_name || template.to_name || "").trim();
|
||||
const final_to_address = (to_address || template.to_address || "")
|
||||
.trim();
|
||||
|
||||
if (final_to_name) {
|
||||
drawTextAt(ctx.page, final_to_name, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 11,
|
||||
font: fonts.bold,
|
||||
color: PdfColors.black,
|
||||
});
|
||||
ctx.y += 14.5;
|
||||
}
|
||||
|
||||
if (final_to_address) {
|
||||
for (const line of final_to_address.split(/\r?\n/)) {
|
||||
drawTextAt(ctx.page, line, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 10.5,
|
||||
font: fonts.regular,
|
||||
color: PdfColors.black,
|
||||
});
|
||||
ctx.y += 13;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.y += 18;
|
||||
|
||||
const salutation = template.salutation?.trim();
|
||||
|
||||
if (salutation) {
|
||||
drawTextAt(ctx.page, salutation, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: BODY_SIZE,
|
||||
font: fonts.regular,
|
||||
color: PdfColors.black,
|
||||
});
|
||||
ctx.y += BODY_LINE_GAP + 6;
|
||||
}
|
||||
|
||||
const blocks = htmlToTextBlocks(template.body_html);
|
||||
|
||||
if (blocks[0]) {
|
||||
renderBlocks({ blocks, fonts, ctx });
|
||||
}
|
||||
|
||||
const closing = template.closing?.trim();
|
||||
const signature_name = template.signature_name?.trim();
|
||||
const signature_title = template.signature_title?.trim();
|
||||
|
||||
if (closing || signature_name) {
|
||||
ensureSpace(ctx, 120);
|
||||
|
||||
ctx.y += 28;
|
||||
|
||||
if (closing) {
|
||||
drawTextAt(ctx.page, closing, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: BODY_SIZE,
|
||||
font: fonts.regular,
|
||||
color: PdfColors.black,
|
||||
});
|
||||
ctx.y += 30;
|
||||
}
|
||||
|
||||
if (signature_name) {
|
||||
drawTextAt(ctx.page, signature_name, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 11.5,
|
||||
font: fonts.bold,
|
||||
color: PdfColors.black,
|
||||
});
|
||||
ctx.y += 15;
|
||||
}
|
||||
|
||||
if (signature_title) {
|
||||
drawTextAt(ctx.page, signature_title, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: 9.5,
|
||||
font: fonts.regular,
|
||||
color: PdfColors.gray,
|
||||
});
|
||||
ctx.y += 13;
|
||||
}
|
||||
}
|
||||
|
||||
drawBrandFooterOnAllPages({
|
||||
pdfDoc,
|
||||
fonts,
|
||||
options: { ...company_block },
|
||||
});
|
||||
|
||||
return await pdfDoc.save();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,257 +0,0 @@
|
||||
import { PDFDocument, type PDFPage } from "pdf-lib";
|
||||
import {
|
||||
PdfColors,
|
||||
PdfMargin,
|
||||
PdfPageHeight,
|
||||
PdfPageWidth,
|
||||
drawBrandHeader,
|
||||
drawStyledLine,
|
||||
drawTextAt,
|
||||
wrapStyledSegments,
|
||||
type PdfColor,
|
||||
type PdfFonts,
|
||||
type StyledLine,
|
||||
} from "./pdf-helpers";
|
||||
import type { TextBlock } from "./html-blocks";
|
||||
|
||||
const FOOTER_SPACE = 140;
|
||||
const BODY_AVAILABLE_WIDTH = PdfPageWidth - PdfMargin * 2;
|
||||
|
||||
export type RenderContext = {
|
||||
pdfDoc: PDFDocument;
|
||||
page: PDFPage;
|
||||
y: number;
|
||||
fonts: PdfFonts;
|
||||
};
|
||||
|
||||
export type BlockRenderStyle = {
|
||||
/** Body font size in pt. */
|
||||
size: number;
|
||||
/** Vertical gap between lines within a block. */
|
||||
line_gap: number;
|
||||
/** Body text color. */
|
||||
color: PdfColor;
|
||||
blockquote_size: number;
|
||||
blockquote_indent: number;
|
||||
/** Extra space after a paragraph/blockquote block. */
|
||||
paragraph_after: number;
|
||||
/** Space before a heading block. */
|
||||
heading_level_gap: number;
|
||||
/** Extra space after a heading block. */
|
||||
heading_after: number;
|
||||
/** Extra space after a list item. */
|
||||
list_after: number;
|
||||
divider_before: number;
|
||||
divider_after: number;
|
||||
};
|
||||
|
||||
const DefaultBlockStyle: BlockRenderStyle = {
|
||||
size: 11,
|
||||
line_gap: 12.5,
|
||||
color: PdfColors.black,
|
||||
blockquote_size: 10,
|
||||
blockquote_indent: 26,
|
||||
paragraph_after: 7,
|
||||
heading_level_gap: 8,
|
||||
heading_after: 6,
|
||||
list_after: 4,
|
||||
divider_before: 8,
|
||||
divider_after: 14,
|
||||
};
|
||||
|
||||
/**
|
||||
* # Compact block style for tight content like invoice/quote notes.
|
||||
* Mirrors the ACCOUNT DETAILS section: small font, subdued color,
|
||||
* minimal margins.
|
||||
*/
|
||||
export const NotesBlockStyle: BlockRenderStyle = {
|
||||
size: 9.5,
|
||||
line_gap: 12.5,
|
||||
color: PdfColors.darkGray,
|
||||
blockquote_size: 9,
|
||||
blockquote_indent: 16,
|
||||
paragraph_after: 3,
|
||||
heading_level_gap: 5,
|
||||
heading_after: 4,
|
||||
list_after: 2,
|
||||
divider_before: 5,
|
||||
divider_after: 8,
|
||||
};
|
||||
|
||||
/**
|
||||
* # Ensure `amount` points of vertical space remain on the current page.
|
||||
* Starts a fresh page (with letterhead) when there isn't enough room.
|
||||
*/
|
||||
export function ensureSpace(ctx: RenderContext, amount: number) {
|
||||
if (ctx.y + amount <= PdfPageHeight - FOOTER_SPACE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = ctx.pdfDoc.addPage([PdfPageWidth, PdfPageHeight]);
|
||||
ctx.page = page;
|
||||
ctx.y = drawBrandHeader({ page, fonts: ctx.fonts });
|
||||
}
|
||||
|
||||
/**
|
||||
* # Render HTML-derived text blocks onto a PDF document.
|
||||
* Shared by letter bodies and invoice/quote notes.
|
||||
*/
|
||||
export function renderBlocks({
|
||||
blocks,
|
||||
fonts,
|
||||
ctx,
|
||||
style = DefaultBlockStyle,
|
||||
}: {
|
||||
blocks: TextBlock[];
|
||||
fonts: PdfFonts;
|
||||
ctx: RenderContext;
|
||||
style?: BlockRenderStyle;
|
||||
}): RenderContext {
|
||||
// Standard WinAnsi fonts cannot encode the ₦ glyph; PDFs use "NGN" instead.
|
||||
for (const block of blocks) {
|
||||
if (block.type == "divider") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const segment of block.segments) {
|
||||
segment.text = segment.text.replace(/\u20a6/g, "NGN ");
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case "paragraph":
|
||||
case "blockquote": {
|
||||
if (block.segments.length == 0) {
|
||||
ctx.y += style.line_gap;
|
||||
continue;
|
||||
}
|
||||
|
||||
const is_blockquote = block.type == "blockquote";
|
||||
const indent = is_blockquote ? style.blockquote_indent : 0;
|
||||
const size = is_blockquote ? style.blockquote_size : style.size;
|
||||
|
||||
const lines = wrapStyledSegments({
|
||||
segments: block.segments,
|
||||
fonts,
|
||||
size,
|
||||
max_width: BODY_AVAILABLE_WIDTH - indent,
|
||||
});
|
||||
|
||||
for (const line of lines) {
|
||||
ensureSpace(ctx, style.line_gap);
|
||||
|
||||
drawStyledLine(ctx.page, line, {
|
||||
x: PdfMargin + indent,
|
||||
y_from_top: ctx.y,
|
||||
size,
|
||||
color: style.color,
|
||||
});
|
||||
ctx.y += style.line_gap;
|
||||
}
|
||||
|
||||
ctx.y += style.paragraph_after;
|
||||
break;
|
||||
}
|
||||
|
||||
case "heading": {
|
||||
const size = style.size + (block.level <= 2 ? 3 : 1.5);
|
||||
|
||||
const lines = wrapStyledSegments({
|
||||
segments: block.segments,
|
||||
fonts,
|
||||
size,
|
||||
max_width: BODY_AVAILABLE_WIDTH,
|
||||
});
|
||||
|
||||
ensureSpace(ctx, lines.length * (size + 8) + 24);
|
||||
ctx.y += style.heading_level_gap;
|
||||
|
||||
for (const line of lines) {
|
||||
ensureSpace(ctx, size + 7);
|
||||
|
||||
const bold_line: StyledLine = {
|
||||
words: line.words.map((word) => ({
|
||||
...word,
|
||||
font:
|
||||
word.font == fonts.italic
|
||||
? fonts.boldItalic
|
||||
: fonts.bold,
|
||||
})),
|
||||
width: line.width,
|
||||
};
|
||||
drawStyledLine(ctx.page, bold_line, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size,
|
||||
color: style.color,
|
||||
});
|
||||
ctx.y += size + 7;
|
||||
}
|
||||
|
||||
ctx.y += style.heading_after;
|
||||
break;
|
||||
}
|
||||
|
||||
case "list-item": {
|
||||
const marker = block.ordered ? `${block.index + 1}.` : "\u2022";
|
||||
const marker_text = `${marker} `;
|
||||
const marker_width = fonts.regular.widthOfTextAtSize(
|
||||
marker_text,
|
||||
style.size,
|
||||
);
|
||||
const hang_indent = 18;
|
||||
const indent = PdfMargin + hang_indent;
|
||||
|
||||
const lines = wrapStyledSegments({
|
||||
segments: block.segments,
|
||||
fonts,
|
||||
size: style.size,
|
||||
max_width:
|
||||
BODY_AVAILABLE_WIDTH - hang_indent - marker_width,
|
||||
});
|
||||
|
||||
for (const [line_index, line] of lines.entries()) {
|
||||
ensureSpace(ctx, style.line_gap);
|
||||
|
||||
if (line_index == 0) {
|
||||
drawTextAt(ctx.page, marker, {
|
||||
x: PdfMargin,
|
||||
y_from_top: ctx.y,
|
||||
size: style.size,
|
||||
font: fonts.regular,
|
||||
color: style.color,
|
||||
});
|
||||
}
|
||||
|
||||
drawStyledLine(ctx.page, line, {
|
||||
x: indent + (line_index == 0 ? marker_width : 0),
|
||||
y_from_top: ctx.y,
|
||||
size: style.size,
|
||||
color: style.color,
|
||||
});
|
||||
ctx.y += style.line_gap;
|
||||
}
|
||||
|
||||
ctx.y += style.list_after;
|
||||
break;
|
||||
}
|
||||
|
||||
case "divider": {
|
||||
ensureSpace(ctx, 30);
|
||||
ctx.y += style.divider_before;
|
||||
ctx.page.drawRectangle({
|
||||
x: PdfMargin,
|
||||
y: ctx.page.getHeight() - ctx.y - 1,
|
||||
width: BODY_AVAILABLE_WIDTH,
|
||||
height: 1,
|
||||
color: PdfColors.gray,
|
||||
});
|
||||
ctx.y += style.divider_after;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { ApiReqParams, ClientAdminCrudParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export default async function adminCrudHandler<
|
||||
T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
|
||||
T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS,
|
||||
>(params: ClientAdminCrudParams<T>) {
|
||||
const {
|
||||
action,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { AdminFetchParams, ApiReqParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export default async function adminFetchHandler<
|
||||
T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
|
||||
T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS,
|
||||
>({ url: passed_url, method = "GET", query, body }: AdminFetchParams<T>) {
|
||||
let url = passed_url;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
@@ -6,7 +6,7 @@ import type { APIResponseObject } from "@moduletrace/bun-sqlite";
|
||||
export default async function clientUploadMediaHandler(params: ApiReqParams) {
|
||||
const res = await fetchApi<
|
||||
ApiReqParams,
|
||||
APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA>
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_MEDIA>
|
||||
>(`/api/admin/upload-media`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* # Client-side actions for document/invoice generation & emailing.
|
||||
* PDF endpoints return binary PDFs (not JSON), so plain fetch is used
|
||||
* here instead of `fetchApi`.
|
||||
*/
|
||||
|
||||
async function postAdminAction({
|
||||
url,
|
||||
body,
|
||||
}: {
|
||||
url: string;
|
||||
body: any;
|
||||
}): Promise<{ success: boolean; msg?: string }> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const content_type = res.headers.get("Content-Type") || "";
|
||||
|
||||
if (content_type.includes("application/json")) {
|
||||
const json = await res.json();
|
||||
return {
|
||||
success: Boolean(json.success),
|
||||
msg: json.msg,
|
||||
};
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message || "Request failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadGeneratedPdf({
|
||||
url,
|
||||
body,
|
||||
fallback_filename,
|
||||
}: {
|
||||
url: string;
|
||||
body: any;
|
||||
fallback_filename?: string;
|
||||
}): Promise<{ success: boolean; msg?: string }> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const content_type = res.headers.get("Content-Type") || "";
|
||||
|
||||
if (content_type.includes("application/json")) {
|
||||
const json = await res.json();
|
||||
return {
|
||||
success: Boolean(json.success),
|
||||
msg: json.msg,
|
||||
};
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
if (blob.size == 0) {
|
||||
throw new Error(`Received an empty PDF`);
|
||||
}
|
||||
|
||||
const disposition = res.headers.get("Content-Disposition") || "";
|
||||
const filename_match = disposition.match(/filename="?([^";]+)"?/i);
|
||||
const filename =
|
||||
filename_match?.[1] || fallback_filename || "document.pdf";
|
||||
|
||||
const object_url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
|
||||
anchor.href = object_url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(object_url);
|
||||
}, 1000);
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message || "Couldn't download document",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailRecord({
|
||||
url,
|
||||
body,
|
||||
}: {
|
||||
url: string;
|
||||
body: any;
|
||||
}) {
|
||||
return await postAdminAction({ url, body });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_BSQLITE_MEDIA, BunSQLiteTables } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA, BunSQLiteTables } from "@/db/types/db";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -24,7 +24,7 @@ export default async function handleMediaServer({
|
||||
throw new Error(`No media privacy params passed`);
|
||||
}
|
||||
|
||||
let media: BUN_SQLITE_BSQLITE_MEDIA | undefined = undefined;
|
||||
let media: BUN_SQLITE_COBBLE_MEDIA | undefined = undefined;
|
||||
|
||||
if (privacy == "private") {
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
@@ -34,7 +34,7 @@ export default async function handleMediaServer({
|
||||
}
|
||||
|
||||
const media_res = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media",
|
||||
@@ -51,7 +51,7 @@ export default async function handleMediaServer({
|
||||
media = media_res.singleRes || undefined;
|
||||
} else {
|
||||
const media_res = await BunSQLite.select<
|
||||
BUN_SQLITE_BSQLITE_MEDIA,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "media",
|
||||
|
||||
@@ -3,12 +3,12 @@ import adminCrudHandler from "../functions/frontend/admin-crud-handler";
|
||||
import _ from "lodash";
|
||||
import { AppData } from "../data/app-data";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { ApiReqParams, ClientAdminCrudParams, TableType } from "../types";
|
||||
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
|
||||
import EJSON from "../utils/ejson";
|
||||
|
||||
type Params<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = Pick<
|
||||
type Params<T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS> = Pick<
|
||||
ClientAdminCrudParams,
|
||||
"id" | "count"
|
||||
> & {
|
||||
@@ -22,7 +22,7 @@ type Params<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = Pick<
|
||||
request_query?: ApiReqParams<T>;
|
||||
};
|
||||
|
||||
export function useAdminCrudGet<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS>({
|
||||
export function useAdminCrudGet<T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS>({
|
||||
table,
|
||||
sql_query,
|
||||
count: add_count_param,
|
||||
|
||||
@@ -3,11 +3,11 @@ import _ from "lodash";
|
||||
import { AppData } from "../data/app-data";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import adminFetchHandler from "../functions/frontend/admin-fetch-handler";
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { AdminFetchParams, ApiReqParams } from "../types";
|
||||
import EJSON from "../utils/ejson";
|
||||
|
||||
type Params<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = Omit<
|
||||
type Params<T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS> = Omit<
|
||||
AdminFetchParams<T>,
|
||||
"method" | "body"
|
||||
> & {
|
||||
@@ -16,7 +16,7 @@ type Params<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = Omit<
|
||||
count?: boolean;
|
||||
};
|
||||
|
||||
export function useFetchGet<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS>({
|
||||
export function useFetchGet<T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS>({
|
||||
url,
|
||||
debug,
|
||||
initial_res,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -15,7 +15,7 @@ import twuiSlugify from "@moduletrace/twui/utils/slugify";
|
||||
import _ from "lodash";
|
||||
import type { AppContextObject } from "../types";
|
||||
|
||||
type Params<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
|
||||
type Params<T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS> = {
|
||||
/**
|
||||
* Existing record for update. All unneccasary fields
|
||||
* like join fields should be excluded.
|
||||
@@ -48,7 +48,7 @@ type Params<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
|
||||
};
|
||||
|
||||
export default function useFormInit<
|
||||
T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
|
||||
T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS,
|
||||
>(params?: Params<T>) {
|
||||
const appContext = useContext(AppContext);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { User } from "../types";
|
||||
import { useEffect, useState } from "react";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
export function useLocalUser() {
|
||||
const [user, setUser] = useState<User>();
|
||||
const [userTypes, setUserTypes] =
|
||||
useState<BUN_SQLITE_BSQLITE_USER_TYPES[]>();
|
||||
useState<BUN_SQLITE_COBBLE_USER_TYPES[]>();
|
||||
|
||||
useEffect(() => {
|
||||
fetchApi<any, APIResponseObject>(`/api/admin/user-auth`).then((res) => {
|
||||
@@ -15,7 +15,7 @@ export function useLocalUser() {
|
||||
setUser(res.singleRes as User);
|
||||
}
|
||||
if (res.payload) {
|
||||
setUserTypes(res.payload as BUN_SQLITE_BSQLITE_USER_TYPES[]);
|
||||
setUserTypes(res.payload as BUN_SQLITE_COBBLE_USER_TYPES[]);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -2,7 +2,7 @@ import _ from "lodash";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import adminFetchHandler from "../functions/frontend/admin-fetch-handler";
|
||||
import type { ApiReqParams } from "../types";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "../types/sql-joins";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "../types/sql-joins";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import EJSON from "../utils/ejson";
|
||||
|
||||
@@ -10,7 +10,7 @@ type Params = {
|
||||
query?: ApiReqParams;
|
||||
debug?: boolean;
|
||||
page?: number;
|
||||
initial_users?: BUN_SQLITE_BSQLITE_USERS_JOIN[];
|
||||
initial_users?: BUN_SQLITE_COBBLE_USERS_JOIN[];
|
||||
};
|
||||
|
||||
export function useUsersGet({
|
||||
@@ -20,7 +20,7 @@ export function useUsersGet({
|
||||
initial_users,
|
||||
}: Params) {
|
||||
const [users, setUsers] = useState<
|
||||
BUN_SQLITE_BSQLITE_USERS_JOIN[] | undefined
|
||||
BUN_SQLITE_COBBLE_USERS_JOIN[] | undefined
|
||||
>(initial_users);
|
||||
const [count, setCount] = useState<number | undefined>();
|
||||
const [page, setPage] = useState<number>(initial_page || 1);
|
||||
@@ -49,7 +49,7 @@ export function useUsersGet({
|
||||
console.log("final_query", final_query);
|
||||
}
|
||||
|
||||
adminFetchHandler<BUN_SQLITE_BSQLITE_USERS_JOIN>({
|
||||
adminFetchHandler<BUN_SQLITE_COBBLE_USERS_JOIN>({
|
||||
url: `/api/admin/grab-users`,
|
||||
query: final_query,
|
||||
})
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import {
|
||||
ArchiveRestore,
|
||||
Bot,
|
||||
DatabaseBackup,
|
||||
FileText,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Mail,
|
||||
Receipt,
|
||||
ScrollText,
|
||||
Settings,
|
||||
SquarePen,
|
||||
UserRound,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
@@ -47,18 +44,10 @@ export const AdminAsideGroups: AdminAsideGroup[] = [
|
||||
Icon: LayoutDashboard,
|
||||
strict: true,
|
||||
},
|
||||
{ title: "Bots", url: "/admin/bots", Icon: Bot },
|
||||
{ title: "People", url: "/admin/people", Icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Documents",
|
||||
links: [
|
||||
{ title: "Letters", url: "/admin/letters", Icon: FileText },
|
||||
{ title: "Emails", url: "/admin/emails", Icon: Mail },
|
||||
{ title: "Invoices", url: "/admin/invoices", Icon: Receipt },
|
||||
{ title: "Quotes", url: "/admin/quotes", Icon: SquarePen },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "System",
|
||||
links: [
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Activity,
|
||||
CircleHelp,
|
||||
Clock,
|
||||
LogOut,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type RailUserMenuItem = {
|
||||
title: string;
|
||||
/**
|
||||
* Unset for destinations that are not built yet; the item renders
|
||||
* disabled.
|
||||
*/
|
||||
url?: string;
|
||||
Icon: LucideIcon;
|
||||
/**
|
||||
* Rendered last, after a rule.
|
||||
*/
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exactly the five popover items. Nothing else lives in the rail: these
|
||||
* destinations are only reachable from here.
|
||||
*/
|
||||
export const RailUserMenuItems: RailUserMenuItem[] = [
|
||||
{ title: "Activity", url: "/admin/logs", Icon: Activity },
|
||||
{ title: "Routines", Icon: Clock },
|
||||
{ title: "Settings", url: "/admin/settings", Icon: SlidersHorizontal },
|
||||
{ title: "Help", Icon: CircleHelp },
|
||||
{ title: "Sign out", url: "/auth/logout", Icon: LogOut, destructive: true },
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import UnreadDot from "./unread-dot";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
selected?: boolean;
|
||||
/**
|
||||
* Replaces the last-message snippet (the coordinator shows crew status).
|
||||
*/
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 48px rail row. Status is the face: no badges, spinners or state dots.
|
||||
* Selected is ink at 5%, never a hue.
|
||||
*/
|
||||
export default function BotRow({ bot, selected, subtitle }: Props) {
|
||||
const hue = (bot.hue || null) as BotHue | null;
|
||||
const snippet = subtitle ?? bot.last_message_text ?? bot.role ?? "";
|
||||
const at = bot.last_message_at || bot.last_activity_at;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/bots/${bot.id}`}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
title={bot.name}
|
||||
className={twMerge(
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg: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 focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
|
||||
selected && "bg-ink/5 hover:bg-ink/5",
|
||||
)}
|
||||
>
|
||||
<BotStone hue={hue} face={(bot.face || "idle") as BotFace} />
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{snippet}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Stack className="hidden lg:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
|
||||
{at ? (
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
{relativeTime({ at: Number(at) })}
|
||||
</Span>
|
||||
) : null}
|
||||
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
|
||||
</Stack>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import { crewStatusLine } from "@/src/utils/crew-status";
|
||||
import BotRow from "./bot-row";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
bots: BotWithLastMessage[];
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pinned first. Hue-less, and its subtitle is the crew status, not a
|
||||
* snippet.
|
||||
*/
|
||||
export default function CoordinatorRow({ bot, bots, selected }: Props) {
|
||||
return (
|
||||
<>
|
||||
<BotRow
|
||||
bot={bot}
|
||||
selected={selected}
|
||||
subtitle={crewStatusLine({ bots })}
|
||||
/>
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
/**
|
||||
* 28px add-bot control. The 1px strong border is required for WCAG 1.4.11.
|
||||
* Add-bot flow is not designed yet, so this has no action.
|
||||
*/
|
||||
export default function NewBotButton() {
|
||||
return (
|
||||
<Button
|
||||
title="Add a bot"
|
||||
color="gray"
|
||||
className="hidden lg:flex size-7 p-0! rounded-default-sm bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink border border-solid border-hairline-strong transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<Span className="relative size-3" aria-hidden>
|
||||
<Span className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[1.5px] bg-ink" />
|
||||
<Span className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-[1.5px] bg-ink" />
|
||||
</Span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Wordmark from "@/src/components/general/wordmark";
|
||||
import LogoIcon from "@/src/components/general/logo-icon";
|
||||
import NewBotButton from "./new-bot-button";
|
||||
|
||||
export default function RailHeader() {
|
||||
return (
|
||||
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
|
||||
<Link
|
||||
href="/admin/bots"
|
||||
className="border-0 mt-1 rounded-default-xs hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<Wordmark height={15} className="hidden lg:block" />
|
||||
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
|
||||
</Link>
|
||||
<NewBotButton />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { RailUserMenuItem } from "../(dict)/rail-user-menu-dict";
|
||||
|
||||
const RowClassName =
|
||||
"w-full h-9 gap-2.5! flex-nowrap items-center px-4";
|
||||
|
||||
export default function RailUserMenuEntry({ title, url, Icon }: RailUserMenuItem) {
|
||||
if (!url) {
|
||||
return (
|
||||
<Row
|
||||
role="menuitem"
|
||||
aria-disabled="true"
|
||||
title={`${title} is not available yet`}
|
||||
className={`${RowClassName} text-ink-disabled cursor-not-allowed`}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
<Span className="text-sm leading-5">{title}</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={url}
|
||||
role="menuitem"
|
||||
className="block w-full border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
|
||||
>
|
||||
<Row className={`${RowClassName} text-ink`}>
|
||||
<Icon size={16} aria-hidden />
|
||||
<Span className="text-sm leading-5 text-ink">{title}</Span>
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
|
||||
import RailUserMenuEntry from "./rail-user-menu-entry";
|
||||
|
||||
/**
|
||||
* 220px popover: the only element in the shell with elevation.
|
||||
*/
|
||||
export default function RailUserMenu() {
|
||||
const items = RailUserMenuItems.filter((item) => !item.destructive);
|
||||
const destructive = RailUserMenuItems.filter((item) => item.destructive);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
role="menu"
|
||||
className="w-[220px] mb-1 p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<RailUserMenuEntry key={item.title} {...item} />
|
||||
))}
|
||||
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
|
||||
{destructive.map((item) => (
|
||||
<RailUserMenuEntry key={item.title} {...item} />
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useContext } from "react";
|
||||
import Dropdown from "@moduletrace/twui/elements/Dropdown";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import RailUserMenu from "./rail-user-menu";
|
||||
|
||||
/**
|
||||
* The signed-in user, opening the popover above. Its top rule sits at the
|
||||
* same height as the composer rule, so the two read as one line.
|
||||
*/
|
||||
export default function RailUserRow() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const user = pageProps?.user;
|
||||
|
||||
const name = user?.first_name || user?.email || "";
|
||||
const initials = [user?.first_name, user?.last_name]
|
||||
.filter(Boolean)
|
||||
.map((part) => part!.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 lg:px-3 mx-0 lg:mx-3 border-0 border-t border-solid border-hairline">
|
||||
<Dropdown
|
||||
position="top-left"
|
||||
className="w-full"
|
||||
targetWrapperProps={{ className: "w-full" }}
|
||||
target={
|
||||
<Row
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`Account menu for ${name}`}
|
||||
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<Span className="size-6 shrink-0 rounded-full bg-sunken flex items-center justify-center text-[11px] font-bold leading-none text-ink">
|
||||
{initials}
|
||||
</Span>
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{name}
|
||||
</Span>
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
Free
|
||||
</Span>
|
||||
</Stack>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="hidden lg:block shrink-0 text-ink"
|
||||
aria-hidden
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
>
|
||||
<RailUserMenu />
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotHueBgClasses } from "@/src/dict/bot-hue-classes-dict";
|
||||
|
||||
/**
|
||||
* 6px unread dot in the bot's own hue. Redundant by design (the snippet and
|
||||
* time carry the same information), which is why it may fail 3:1 on some
|
||||
* hues. Never remove the snippet or time.
|
||||
*/
|
||||
export default function UnreadDot({ hue }: { hue: BotHue }) {
|
||||
return (
|
||||
<Span
|
||||
className={twMerge("size-1.5 rounded-full mr-1", BotHueBgClasses[hue])}
|
||||
aria-label="Unread"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useContext } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import RailHeader from "../(partials)/rail-header";
|
||||
import CoordinatorRow from "../(partials)/coordinator-row";
|
||||
import BotRow from "../(partials)/bot-row";
|
||||
import RailUserRow from "../(partials)/rail-user-row";
|
||||
|
||||
/**
|
||||
* Left rail: wordmark, the pinned coordinator, then the crew. Collapses to a
|
||||
* 64px icon rail below 1024px.
|
||||
*/
|
||||
export default function BotsRail() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
const bots = pageProps?.bots || [];
|
||||
const selected_id = pageProps?.bot?.id;
|
||||
const coordinator = bots.find((bot) => bot.kind === "coordinator");
|
||||
const workers = bots.filter((bot) => bot.kind !== "coordinator");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
|
||||
aria-label="Bots"
|
||||
>
|
||||
<RailHeader />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1">
|
||||
{coordinator ? (
|
||||
<CoordinatorRow
|
||||
bot={coordinator}
|
||||
bots={bots}
|
||||
selected={coordinator.id === selected_id}
|
||||
/>
|
||||
) : null}
|
||||
{workers.map((bot) => (
|
||||
<BotRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
selected={bot.id === selected_id}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<RailUserRow />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import BotsRail from "./(sections)/bots-rail";
|
||||
|
||||
/**
|
||||
* Full-bleed chat shell for `/admin/bots/*`: the rail of bot conversations,
|
||||
* then the page (conversation + working-on panel). No cards, no shadows;
|
||||
* regions are separated by 1px hairlines.
|
||||
*/
|
||||
export default function BotsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink">
|
||||
<BotsRail />
|
||||
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch">
|
||||
{children}
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
+10
-1
@@ -12,6 +12,7 @@ import { SiteData } from "@/src/data/site-data";
|
||||
import Layout from "../layouts/main";
|
||||
import AdminLayout from "../layouts/admin";
|
||||
import AuthLayout from "../layouts/auth";
|
||||
import BotsLayout from "../layouts/bots";
|
||||
import ThemeInitScript from "../components/general/theme-init-script";
|
||||
|
||||
export const AppContext = React.createContext<AppContextObject>(
|
||||
@@ -46,6 +47,14 @@ export default function App({
|
||||
query,
|
||||
};
|
||||
|
||||
if (url?.pathname.startsWith("/admin/bots")) {
|
||||
return (
|
||||
<AppContext.Provider value={appContextObject}>
|
||||
<BotsLayout>{children}</BotsLayout>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (url?.pathname.startsWith("/admin")) {
|
||||
return (
|
||||
<AppContext.Provider value={appContextObject}>
|
||||
@@ -94,7 +103,7 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
||||
crossOrigin=""
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:[email protected]&family=Geist+Mono:wght@400..600&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 24x24 32x32 48x48" />
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
FileText,
|
||||
Mail,
|
||||
Receipt,
|
||||
SquarePen,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Bot, Users, type LucideIcon } from "lucide-react";
|
||||
|
||||
export type DashboardShortcut = {
|
||||
title: string;
|
||||
@@ -16,28 +9,10 @@ export type DashboardShortcut = {
|
||||
|
||||
export const DashboardShortcuts: DashboardShortcut[] = [
|
||||
{
|
||||
title: "Invoices",
|
||||
description: "Bill clients, track payment status and export PDFs.",
|
||||
url: "/admin/invoices",
|
||||
Icon: Receipt,
|
||||
},
|
||||
{
|
||||
title: "Quotes",
|
||||
description: "Draft priced proposals and convert them to invoices.",
|
||||
url: "/admin/quotes",
|
||||
Icon: SquarePen,
|
||||
},
|
||||
{
|
||||
title: "Letters",
|
||||
description: "Compose letterhead documents ready to print or send.",
|
||||
url: "/admin/letters",
|
||||
Icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Emails",
|
||||
description: "Write, attach and send branded email from one place.",
|
||||
url: "/admin/emails",
|
||||
Icon: Mail,
|
||||
title: "Bots",
|
||||
description: "Talk to your crew, see what they are working on and answer what needs you.",
|
||||
url: "/admin/bots",
|
||||
Icon: Bot,
|
||||
},
|
||||
{
|
||||
title: "People",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
|
||||
export default function EmptyCrew() {
|
||||
return (
|
||||
<Center className="grow h-full">
|
||||
<Stack className="items-center gap-3!">
|
||||
<BotStone face="asleep" size={64} />
|
||||
<Span className="text-sm font-bold text-ink">No bots yet</Span>
|
||||
<Span className="text-sm text-ink-secondary">
|
||||
Add a bot and give it a job.
|
||||
</Span>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { SendBotMessageRequest } from "@/src/schemas/bot-schema";
|
||||
|
||||
export default async function sendBotMessage({
|
||||
bot_id,
|
||||
text,
|
||||
}: SendBotMessageRequest) {
|
||||
return await fetchApi<
|
||||
SendBotMessageRequest,
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_BOT_MESSAGES>
|
||||
>(`/api/admin/bots/send-message`, {
|
||||
method: "POST",
|
||||
body: { bot_id, text },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import BotMessageLine from "./bot-message-line";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
group: MessageGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plain text, no bubble. A 28px stone sits on the group's first line only.
|
||||
*/
|
||||
export default function BotMessageGroup({ bot, group }: Props) {
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start">
|
||||
<BotStone
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={28}
|
||||
className="-mt-1"
|
||||
/>
|
||||
<Stack className="min-w-0 grow gap-1! items-start">
|
||||
{group.messages.map((message) => (
|
||||
<BotMessageLine
|
||||
key={message.id}
|
||||
text={message.text || ""}
|
||||
needs_you={Boolean(message.needs_you)}
|
||||
/>
|
||||
))}
|
||||
<GroupTimestamp at={group.at} />
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import NeedsYouChip from "./needs-you-chip";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
needs_you?: boolean;
|
||||
};
|
||||
|
||||
export default function BotMessageLine({ text, needs_you }: Props) {
|
||||
return (
|
||||
<Stack className="gap-1.5! items-start max-w-full">
|
||||
<P noMargin className="text-sm leading-5 text-ink whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</P>
|
||||
{needs_you ? <NeedsYouChip /> : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Attachments are not designed yet; the control is present but inert.
|
||||
*/
|
||||
export default function ComposerAttachButton() {
|
||||
return (
|
||||
<Button
|
||||
title="Attachments are not available yet"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
aria-disabled="true"
|
||||
className="shrink-0 p-1! -m-1 text-ink dark:text-ink cursor-not-allowed rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 32px ink send button with a paper arrow.
|
||||
*/
|
||||
export default function ComposerSendButton({ disabled, loading, onClick }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title="Send"
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
loadingIconSize="smaller"
|
||||
onClick={onClick}
|
||||
className="shrink-0 size-8 p-0! rounded-default focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<ArrowUp size={16} aria-hidden />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
onSent: (params: { message: BUN_SQLITE_COBBLE_BOT_MESSAGES }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
|
||||
* Its top rule lines up with the rail's user-row rule.
|
||||
*/
|
||||
export default function Composer({ bot, onSent }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const can_send = Boolean(text.trim()) && !sending;
|
||||
|
||||
async function send() {
|
||||
if (!can_send || !bot.id) return;
|
||||
|
||||
setSending(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await sendBotMessage({ bot_id: bot.id, text });
|
||||
|
||||
setSending(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Message not sent");
|
||||
return;
|
||||
}
|
||||
|
||||
setText("");
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline">
|
||||
<ComposerAttachButton />
|
||||
<Textarea
|
||||
name="message"
|
||||
aria-label={`Message ${bot.name}`}
|
||||
placeholder={`Message ${bot.name}`}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-invalid={error ? true : undefined}
|
||||
title={error}
|
||||
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
|
||||
wrapperWrapperProps={{ className: "grow min-w-0" }}
|
||||
wrapperProps={{
|
||||
className:
|
||||
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
|
||||
}}
|
||||
/>
|
||||
<ComposerSendButton disabled={!can_send} loading={sending} onClick={send} />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import { crewStatusSentence } from "@/src/utils/crew-status";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
bots: BotWithLastMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 64px header. The coordinator's status is the crew sentence.
|
||||
*/
|
||||
export default function ConversationHeader({ bot, bots }: Props) {
|
||||
const status =
|
||||
bot.kind === "coordinator"
|
||||
? crewStatusSentence({ bots })
|
||||
: bot.status_line || "";
|
||||
|
||||
return (
|
||||
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
|
||||
<BotStone
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
<Stack className="min-w-0 gap-0!">
|
||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
|
||||
{bot.name}
|
||||
</H1>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate">
|
||||
{status}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
|
||||
export default function GroupTimestamp({ at }: { at: number }) {
|
||||
return (
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
{relativeTime({ at })}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import UserMessageGroup from "./user-message-group";
|
||||
import BotMessageGroup from "./bot-message-group";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
group: MessageGroup;
|
||||
};
|
||||
|
||||
export default function MessageGroupBlock({ bot, group }: Props) {
|
||||
if (group.author === "user") {
|
||||
return <UserMessageGroup group={group} />;
|
||||
}
|
||||
|
||||
return <BotMessageGroup bot={bot} group={group} />;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import groupMessages from "../(utils)/group-messages";
|
||||
import MessageGroupBlock from "./message-group-block";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Bottom-anchored: the stack sits 24px above the composer rule and grows
|
||||
* upward. Empty space belongs at the top. New messages announce politely.
|
||||
*/
|
||||
export default function MessageThread({ bot, messages }: Props) {
|
||||
const groups = groupMessages({ messages });
|
||||
|
||||
return (
|
||||
<Stack className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
|
||||
<Stack
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label={`Conversation with ${bot.name}`}
|
||||
className="w-full gap-4! items-stretch px-6 pt-6 pb-6"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock key={group.key} bot={bot} group={group} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
/**
|
||||
* Belongs to the message, never the avatar.
|
||||
*/
|
||||
export default function NeedsYouChip() {
|
||||
return (
|
||||
<Span className="inline-flex h-5 items-center px-3 rounded-default-xs bg-sunken text-[11px] font-bold leading-4 text-ink">
|
||||
Needs you
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
/**
|
||||
* Opens the machine the bot is working on. Disabled until the task has one.
|
||||
*/
|
||||
export default function OpenComputerButton({ url }: { url?: string | null }) {
|
||||
return (
|
||||
<Button
|
||||
title="Open computer"
|
||||
color="gray"
|
||||
href={url || undefined}
|
||||
target="_blank"
|
||||
disabled={!url}
|
||||
className="w-full h-9 rounded-default text-[13px] font-bold bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink border border-solid border-hairline-strong transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
Open computer
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 14px step mark. Pending has no mark; its label is muted.
|
||||
*/
|
||||
export default function TaskStepMark({ state }: { state: string }) {
|
||||
if (state === "done") {
|
||||
return (
|
||||
<Check
|
||||
size={14}
|
||||
strokeWidth={1.6 * (24 / 14)}
|
||||
className="shrink-0 text-ink"
|
||||
aria-label="Done"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "active") {
|
||||
return (
|
||||
<Span className="size-3.5 shrink-0 flex items-center justify-center" aria-label="In progress" role="img">
|
||||
<Span className="size-1.5 rounded-full bg-ink" />
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
return <Span className="size-3.5 shrink-0" aria-hidden />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import TaskStepMark from "./task-step-mark";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 28px pitch. Done is a tick, active a dot with a bold label.
|
||||
*/
|
||||
export default function TaskStepRow({ label, state }: Props) {
|
||||
const is_active = state === "active";
|
||||
|
||||
return (
|
||||
<Row
|
||||
className="h-7 gap-2.5! flex-nowrap items-center"
|
||||
aria-current={is_active ? "step" : undefined}
|
||||
>
|
||||
<TaskStepMark state={state} />
|
||||
<Span
|
||||
className={twMerge(
|
||||
"text-[13px] leading-5 truncate",
|
||||
is_active ? "font-bold text-ink" : "text-ink-secondary",
|
||||
state === "pending" && "text-ink-disabled",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user