Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74da091dd4 | ||
|
|
a33593460b | ||
|
|
bd73ecbe12 | ||
|
|
50dd6976ee | ||
|
|
8c4658f6e3 | ||
|
|
d3ce787087 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cobble",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { ChatroomBot } from "@/src/types";
|
||||
import ChatroomStone from "./chatroom-stone";
|
||||
|
||||
type Props = {
|
||||
bots: ChatroomBot[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The square the cluster fills (`w-7 h-7`), the size of a rail avatar.
|
||||
*/
|
||||
const ClusterSize = 28;
|
||||
|
||||
/**
|
||||
* A chatroom's bots packed into one avatar-sized square: the asking bot
|
||||
* on top, the two below it in a triangle (four make a square, the rest
|
||||
* are left out). Stones stay small and apart: never overlapping, in a
|
||||
* badge or outlined. One bot fills the square alone.
|
||||
*/
|
||||
export default function ChatroomStoneCluster({ bots }: Props) {
|
||||
const shown = bots.slice(0, 4);
|
||||
const stone = Math.floor((ClusterSize - 2) / 2);
|
||||
|
||||
if (shown.length <= 1) {
|
||||
return shown[0] ? <ChatroomStone bot={shown[0]} size={ClusterSize} /> : null;
|
||||
}
|
||||
|
||||
const top = shown.length == 3 ? shown.slice(0, 1) : shown.slice(0, 2);
|
||||
const bottom = shown.length == 2 ? [] : shown.slice(top.length);
|
||||
|
||||
return (
|
||||
<Stack className="shrink-0 gap-0.5! items-center justify-center w-7 h-7">
|
||||
<Row className="gap-0.5! flex-nowrap justify-center">
|
||||
{top.map((bot) => (
|
||||
<ChatroomStone key={bot.id} bot={bot} size={stone} />
|
||||
))}
|
||||
</Row>
|
||||
{bottom.length ? (
|
||||
<Row className="gap-0.5! flex-nowrap justify-center">
|
||||
{bottom.map((bot) => (
|
||||
<ChatroomStone key={bot.id} bot={bot} size={stone} />
|
||||
))}
|
||||
</Row>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { TaskStatuses } from "@/src/dict/task-dict";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import taskPlaceLabel from "@/src/utils/task-place-label";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import RowIconButton from "./row-icon-button";
|
||||
import TaskStatusChip from "./task-status-chip";
|
||||
|
||||
type Props = {
|
||||
@@ -24,6 +26,10 @@ type Props = {
|
||||
* Moves it to one status; only crew managers get the controls.
|
||||
*/
|
||||
onStatusChange?: (status: string) => void;
|
||||
/**
|
||||
* Deletes it after asking; only crew managers get the button.
|
||||
*/
|
||||
onDelete?: () => void;
|
||||
busy?: boolean;
|
||||
};
|
||||
|
||||
@@ -32,7 +38,7 @@ type Props = {
|
||||
* is and who has it, what it's blocked on, its latest note and how long ago it
|
||||
* moved.
|
||||
*/
|
||||
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
|
||||
export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, busy }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||
const done = task.status == "done" || task.status == "cancelled";
|
||||
@@ -61,6 +67,14 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
|
||||
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
|
||||
</Button>
|
||||
<TaskStatusChip status={task.status} />
|
||||
{can_manage && onDelete ? (
|
||||
<RowIconButton
|
||||
icon={Trash2}
|
||||
title={`Delete ${task.title || `task ${task.id}`}`}
|
||||
disabled={busy}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</Row>
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
|
||||
|
||||
@@ -14,15 +15,20 @@ export default function TaskStatusChip({ status = "todo" }: Props) {
|
||||
const value = (status || "todo") as TaskStatus;
|
||||
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
|
||||
|
||||
const tone =
|
||||
value == "blocked"
|
||||
? "text-ink border-ink"
|
||||
: value == "doing"
|
||||
? "text-ink border-hairline-strong"
|
||||
: "text-ink-secondary border-hairline";
|
||||
|
||||
/**
|
||||
* A `Tag` is a filled primary button by default (ink in light mode), so
|
||||
* the chip clears its fill and sets its own text: only the outline speaks.
|
||||
*/
|
||||
return (
|
||||
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}>
|
||||
<Tag
|
||||
className={twMerge(
|
||||
"shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid",
|
||||
"bg-transparent! dark:bg-transparent! hover:bg-transparent!",
|
||||
value == "blocked" && "text-ink! dark:text-ink! border-ink",
|
||||
value == "doing" && "text-ink! dark:text-ink! border-hairline-strong",
|
||||
value != "blocked" && value != "doing" && "text-ink-secondary! dark:text-ink-secondary! border-hairline",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
+21
-1
@@ -160,6 +160,20 @@ export const PwaDefaultInstallIcon: PwaInstallIcon = {
|
||||
export const PwaCornerMarkInset = 0.2;
|
||||
export const PwaCornerMarkScale = 0.22;
|
||||
|
||||
/**
|
||||
* The corner mark on the regular (rounded-square) icons, which aren't cut
|
||||
* to a circle: larger and nearer the corner, clear of its rounding.
|
||||
*/
|
||||
export const PwaSquareCornerMarkInset = 0.16;
|
||||
export const PwaSquareCornerMarkScale = 0.3;
|
||||
|
||||
/**
|
||||
* The corner mark on a round favicon: larger, to stay readable at 32px,
|
||||
* and still inside the circle.
|
||||
*/
|
||||
export const PwaFaviconCornerMarkInset = 0.15;
|
||||
export const PwaFaviconCornerMarkScale = 0.32;
|
||||
|
||||
/**
|
||||
* Uploads must be at least this many pixels on their shorter side, and are
|
||||
* kept as a square of the source size.
|
||||
@@ -167,12 +181,18 @@ export const PwaCornerMarkScale = 0.22;
|
||||
export const PwaInstallIconMinSide = 512;
|
||||
export const PwaInstallIconSourceSide = 1024;
|
||||
|
||||
/**
|
||||
* Bump when `renderInstallIcons` draws differently: it's part of a set's
|
||||
* version, so a re-saved icon gets a new URL past the `immutable` cache.
|
||||
*/
|
||||
export const PwaInstallIconRenderRevision = 4;
|
||||
|
||||
export type PwaInstallIconFile = {
|
||||
name: string;
|
||||
size: number;
|
||||
/**
|
||||
* `plain` is the image without the mark (the settings preview);
|
||||
* `favicon` never gets the mark (unreadable at 32px).
|
||||
* `favicon` is cut to a circle, the mark kept inside it.
|
||||
*/
|
||||
kind: "any" | "maskable" | "favicon" | "plain";
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
PwaInstallIconRenderRevision,
|
||||
PwaInstallIconSettingKey,
|
||||
type PwaInstallIcon,
|
||||
type PwaInstallIconMode,
|
||||
@@ -57,7 +58,7 @@ export default async function saveInstallIcon({ mode, stone, mark, image }: Para
|
||||
const version = is_stock
|
||||
? null
|
||||
: createHash("sha256")
|
||||
.update(JSON.stringify({ mode, stone, mark }))
|
||||
.update(JSON.stringify({ mode, stone, mark, render: PwaInstallIconRenderRevision }))
|
||||
.update(source || "")
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import sharp from "sharp";
|
||||
import sharp, { type OverlayOptions } from "sharp";
|
||||
import {
|
||||
PwaAppIconMarkScale,
|
||||
PwaCornerMarkInset,
|
||||
PwaCornerMarkScale,
|
||||
PwaFaviconCornerMarkInset,
|
||||
PwaFaviconCornerMarkScale,
|
||||
PwaInstallIconFiles,
|
||||
PwaMaskableMarkScale,
|
||||
PwaSquareCornerMarkInset,
|
||||
PwaSquareCornerMarkScale,
|
||||
type PwaInstallIcon,
|
||||
type PwaInstallIconFile,
|
||||
} from "@/src/data/pwa-data";
|
||||
@@ -26,7 +30,8 @@ type Params = {
|
||||
/**
|
||||
* Every file of an install's icon set (`PwaInstallIconFiles`), by name.
|
||||
* A stone icon is the stone on paper, like the stock one. An image fills
|
||||
* the icon, with the stone in the top-left corner when `mark` is on.
|
||||
* the icon, with the stone in the top-left corner when `mark` is on; its
|
||||
* favicon is cut to a circle, the mark kept inside it.
|
||||
*/
|
||||
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
|
||||
const files: Record<string, Buffer> = {};
|
||||
@@ -79,19 +84,41 @@ async function renderImageFile({
|
||||
.resize(file.size, file.size, { fit: "cover" })
|
||||
.flatten({ background });
|
||||
|
||||
if (!mark || file.kind == "favicon" || file.kind == "plain") {
|
||||
return base.png().toBuffer();
|
||||
}
|
||||
const layers: OverlayOptions[] = [];
|
||||
|
||||
const mark_size = Math.max(1, Math.round(file.size * PwaCornerMarkScale));
|
||||
const inset = Math.round(file.size * PwaCornerMarkInset);
|
||||
if (mark && file.kind != "plain") {
|
||||
const { scale, inset_scale } = markPlacement({ kind: file.kind });
|
||||
const mark_size = Math.max(1, Math.round(file.size * scale));
|
||||
const inset = Math.round(file.size * inset_scale);
|
||||
const stone = await sharp(stone_svg)
|
||||
.resize(mark_size, mark_size, { fit: "contain" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return base
|
||||
.composite([{ input: stone, left: inset, top: inset }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
layers.push({ input: stone, left: inset, top: inset });
|
||||
}
|
||||
|
||||
if (file.kind == "favicon") {
|
||||
const half = file.size / 2;
|
||||
const circle = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${file.size}" height="${file.size}"><circle cx="${half}" cy="${half}" r="${half}"/></svg>`,
|
||||
);
|
||||
|
||||
layers.push({ input: circle, blend: "dest-in" });
|
||||
}
|
||||
|
||||
// Layers apply in order, so the circle cuts the mark too.
|
||||
return base.ensureAlpha().composite(layers).png().toBuffer();
|
||||
}
|
||||
|
||||
function markPlacement({ kind }: { kind: PwaInstallIconFile["kind"] }) {
|
||||
if (kind == "favicon") {
|
||||
return { scale: PwaFaviconCornerMarkScale, inset_scale: PwaFaviconCornerMarkInset };
|
||||
}
|
||||
|
||||
if (kind == "any") {
|
||||
return { scale: PwaSquareCornerMarkScale, inset_scale: PwaSquareCornerMarkInset };
|
||||
}
|
||||
|
||||
return { scale: PwaCornerMarkScale, inset_scale: PwaCornerMarkInset };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import ChatroomStone from "@/src/components/general/chatroom-stone";
|
||||
import ChatroomStoneCluster from "@/src/components/general/chatroom-stone-cluster";
|
||||
import type { ChatroomSummary } from "@/src/types";
|
||||
import chatroomStatusLine from "@/src/utils/chatroom-status-line";
|
||||
|
||||
@@ -13,12 +13,11 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A chatroom in the rail, the same 48px row as a bot: the asking bot's
|
||||
* stone, the request and where it stands. Unread is said in words.
|
||||
* A chatroom in the rail, the same 48px row as a bot: its bots' stones
|
||||
* in one avatar-sized cluster, the request and where it stands. Unread
|
||||
* is said in words.
|
||||
*/
|
||||
export default function RailChatroomRow({ chatroom, href, selected }: Props) {
|
||||
const requester = chatroom.bots[0];
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
@@ -32,7 +31,7 @@ export default function RailChatroomRow({ chatroom, href, selected }: Props) {
|
||||
)}
|
||||
>
|
||||
<Span className="mx-auto @rail:mx-0 inline-flex shrink-0">
|
||||
{requester ? <ChatroomStone bot={requester} size={28} /> : null}
|
||||
<ChatroomStoneCluster bots={chatroom.bots} />
|
||||
</Span>
|
||||
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
|
||||
@@ -80,6 +80,29 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
replace({ task: res.singleRes });
|
||||
};
|
||||
|
||||
const remove = async ({ task }: { task: TaskWithNames }) => {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
|
||||
"/api/admin/tasks/delete-task",
|
||||
{ method: "POST", body: { task_id: Number(task.id) } },
|
||||
);
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success) {
|
||||
set_status(res.msg || "That didn't work.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTasks((prev) => prev.filter((item) => item.id != task.id));
|
||||
set_status("Task deleted.");
|
||||
};
|
||||
|
||||
const show = async ({ task }: { task: TaskWithNames }) => {
|
||||
set_status(undefined);
|
||||
|
||||
@@ -220,6 +243,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
busy={busy}
|
||||
onOpen={() => show({ task }).catch(() => {})}
|
||||
onStatusChange={(next) => move({ task, next })}
|
||||
onDelete={() => remove({ task }).catch(() => {})}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -20,7 +20,8 @@ type Props = {
|
||||
/**
|
||||
* The app icon as a launcher would show it, laid out like the generated
|
||||
* files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper,
|
||||
* or the image with the stone 20% in from the top-left at 22% of the side.
|
||||
* or the image with the stone in the top-left: 20% in at 22% of the side
|
||||
* (maskable, round), 16% in at 30% (rounded square).
|
||||
*/
|
||||
export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
|
||||
const stone_src = `/brand/icons/${stone}.svg`;
|
||||
@@ -41,7 +42,12 @@ export default function InstallIconShape({ shape, mode, stone, mark, image_src,
|
||||
<Img
|
||||
src={stone_src}
|
||||
alt=""
|
||||
className="absolute left-[20%] top-[20%] size-[22%]"
|
||||
className={twMerge(
|
||||
"absolute",
|
||||
shape == "circle"
|
||||
? "left-[20%] top-[20%] size-[22%]"
|
||||
: "left-[16%] top-[16%] size-[30%]",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -62,6 +62,29 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
replace({ task: res.singleRes });
|
||||
};
|
||||
|
||||
const remove = async ({ task }: { task: TaskWithNames }) => {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
|
||||
"/api/admin/tasks/delete-task",
|
||||
{ method: "POST", body: { task_id: Number(task.id) } },
|
||||
);
|
||||
|
||||
setBusy(false);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) => prev.filter((item) => item.id != task.id));
|
||||
setStatus({ success: true, msg: "Task deleted." });
|
||||
};
|
||||
|
||||
const show = async ({ task }: { task: TaskWithNames }) => {
|
||||
setStatus(undefined);
|
||||
|
||||
@@ -142,6 +165,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
busy={busy}
|
||||
onOpen={() => show({ task }).catch(() => {})}
|
||||
onStatusChange={(next) => move({ task, next })}
|
||||
onDelete={() => remove({ task }).catch(() => {})}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
|
||||
Reference in New Issue
Block a user