Feature: expanded and custom task icons (Lucide + Font Awesome + emoji) (#248)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #248.
This commit is contained in:
2026-09-26 09:02:07 -04:00
committed by approver_agent
parent fb9907ae7f
commit bdf6d0aca1
33 changed files with 5108 additions and 1023 deletions
+84 -19
View File
@@ -43,7 +43,18 @@ import {
PlusGlyph,
TaskGlyph,
} from "./glyphs";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import {
DEFINITION_ICONS,
DefinitionIcon,
FA_DEFINITION_ICONS,
emojiIconKey,
emojiIconText,
faIconKey,
faIconName,
lucideIconKey,
normalizeDefinitionIconName,
} from "./definitionIcons";
import { GlyphSvg } from "../icons/GlyphSvg";
import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -78,8 +89,13 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
interface Draft {
title: string;
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
/**
* The stored icon key (`lucide:`, `fa:` or `emoji:`); a legacy value
* outside the curated sets is kept until replaced.
*/
icon: string | null;
/** The emoji field's text; typing in it sets `icon` to `emoji:<text>`. */
emoji: string;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -120,6 +136,7 @@ function newDraft(activeChildren: AdminChild[]): Draft {
return {
title: "",
icon: null,
emoji: "",
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
@@ -145,6 +162,7 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
return {
title: definition.title,
icon: definition.icon,
emoji: emojiIconText(definition.icon),
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -231,6 +249,9 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
if (!title) return "Give the task a title.";
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
if (draft.windows.length === 0) return "Pick at least one window.";
if (draft.emoji && draft.icon === null) {
return "Enter a single emoji (no spaces or < > &), or clear the emoji field.";
}
const rule = buildRule(draft);
if (typeof rule === "string") return rule;
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
@@ -441,6 +462,10 @@ function EditSheet({
// assignee_child_ids, because the API replaces the whole set and inactive
// children cannot be sent back.
const [originalActiveIds] = useState(() => draft.assigneeIds);
// The icon as loaded: while it is unchanged, the PATCH omits `icon` (absent
// means unchanged), because a legacy value read back as `lucide:<old>` would
// fail the API's strict icon validation if sent back.
const [originalIcon] = useState(() => draft.icon);
const inactiveAssignees =
target === "new"
? []
@@ -523,6 +548,7 @@ function EditSheet({
} else {
const edit: DefinitionEditBody = { ...body };
if (keepsAssignees) delete edit.assignee_child_ids;
if (draft.icon === originalIcon) delete edit.icon;
await updateDefinition(target.id, edit);
}
onSaved();
@@ -569,28 +595,67 @@ function EditSheet({
<button
type="button"
role="radio"
aria-checked={draft.icon === null}
aria-checked={draft.icon === null && !draft.emoji}
aria-label="No icon"
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null })}
className={
draft.icon === null && !draft.emoji ? "defs-icon defs-icon--on" : "defs-icon"
}
onClick={() => update({ icon: null, emoji: "" })}
>
<TaskGlyph size={18} />
</button>
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
<button
key={name}
type="button"
role="radio"
aria-checked={draft.icon === name}
aria-label={label}
data-icon-option={name}
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: name })}
>
<Icon size={18} aria-hidden="true" focusable="false" />
</button>
))}
{DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = normalizeDefinitionIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={name}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: lucideIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
<span className="defs-sublabel" id="defs-label-icon-fa">
Font Awesome
</span>
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon-fa">
{FA_DEFINITION_ICONS.map((icon) => {
const { name, label } = icon;
const on = faIconName(draft.icon) === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={label}
data-icon-option={faIconKey(name)}
className={on ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: faIconKey(name), emoji: "" })}
>
<GlyphSvg icon={icon} size={18} />
</button>
);
})}
</div>
<label className="defs-due">
<span>Emoji</span>
<input
className="defs-input"
aria-label="Emoji"
placeholder="e.g. 🐶"
value={draft.emoji}
onChange={(e) => update({ emoji: e.target.value, icon: emojiIconKey(e.target.value) })}
/>
</label>
</Field>
<Field label="Assigned children" id="defs-label-children">