From bf553db99a449c8ec37919d03b1a771e9bbd1028 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Tue, 11 Nov 2025 18:31:40 -0800 Subject: [PATCH 1/9] infra: give deploy user pulumi KMS access --- pulumi/infra/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pulumi/infra/index.ts b/pulumi/infra/index.ts index 56e45db..e8c68de 100644 --- a/pulumi/infra/index.ts +++ b/pulumi/infra/index.ts @@ -159,6 +159,14 @@ if (stack === "prod") { member: pulumi.interpolate`serviceAccount:${deploySA.email}`, }) + // Grant deploy SA access to decrypt Pulumi state secrets + // The KMS key was created manually: csheet-pulumi/infra in us-central1 + new gcp.kms.CryptoKeyIAMMember("deploy-kms-decrypt", { + cryptoKeyId: "projects/csheet-475917/locations/us-central1/keyRings/csheet-pulumi/cryptoKeys/infra", + role: "roles/cloudkms.cryptoKeyDecrypter", + member: pulumi.interpolate`serviceAccount:${deploySA.email}`, + }) + // Allow admin user to impersonate the deploy service account (for local testing) new gcp.serviceaccount.IAMMember("admin-impersonates-deploy", { serviceAccountId: deploySA.name, From 02b75b473e5f5f2885de4282aab79419b28901d7 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Tue, 11 Nov 2025 21:21:21 -0800 Subject: [PATCH 2/9] feat: show spell cast results both afterwards for attacks, and reed learns the stuff also --- pulumi/infra/index.ts | 3 +- src/components/SpellCastResult.tsx | 74 +++++++++++++++- src/lib/spellFormatters.ts | 14 +++ src/routes/character.tsx | 6 +- src/services/castSpell.ts | 136 ++++++++++++++++++++++++++++- 5 files changed, 227 insertions(+), 6 deletions(-) diff --git a/pulumi/infra/index.ts b/pulumi/infra/index.ts index e8c68de..10251fe 100644 --- a/pulumi/infra/index.ts +++ b/pulumi/infra/index.ts @@ -162,7 +162,8 @@ if (stack === "prod") { // Grant deploy SA access to decrypt Pulumi state secrets // The KMS key was created manually: csheet-pulumi/infra in us-central1 new gcp.kms.CryptoKeyIAMMember("deploy-kms-decrypt", { - cryptoKeyId: "projects/csheet-475917/locations/us-central1/keyRings/csheet-pulumi/cryptoKeys/infra", + cryptoKeyId: + "projects/csheet-475917/locations/us-central1/keyRings/csheet-pulumi/cryptoKeys/infra", role: "roles/cloudkms.cryptoKeyDecrypter", member: pulumi.interpolate`serviceAccount:${deploySA.email}`, }) diff --git a/src/components/SpellCastResult.tsx b/src/components/SpellCastResult.tsx index 09899d5..8e414c8 100644 --- a/src/components/SpellCastResult.tsx +++ b/src/components/SpellCastResult.tsx @@ -1,13 +1,15 @@ import { spells } from "@src/lib/dnd/spells" +import type { AttackInfo } from "@src/services/castSpell" import { SpellDetail } from "./SpellDetail" import { ModalContent } from "./ui/ModalContent" export interface SpellCastResultProps { message: string spellId: string + attackInfo?: AttackInfo } -export const SpellCastResult = ({ message, spellId }: SpellCastResultProps) => { +export const SpellCastResult = ({ message, spellId, attackInfo }: SpellCastResultProps) => { const spell = spells.find((s) => s.id === spellId) if (!spell) { @@ -37,6 +39,76 @@ export const SpellCastResult = ({ message, spellId }: SpellCastResultProps) => { {message} + {/* Attack/Combat Results */} + {attackInfo && ( +
+
+ + Cast Results +
+
+ {/* Attack bonus for attack spells */} + {attackInfo.attackBonus !== undefined && ( +
+ Attack Roll:{" "} + + {attackInfo.attackBonus >= 0 ? "+" : ""} + {attackInfo.attackBonus} to hit + + (ranged spell attack) +
+ )} + + {/* Save DC for save spells */} + {attackInfo.saveDC !== undefined && attackInfo.saveAbility && ( +
+ Saving Throw:{" "} + DC {attackInfo.saveDC} + + {attackInfo.saveAbility.charAt(0).toUpperCase() + + attackInfo.saveAbility.slice(1)}{" "} + save + {attackInfo.onSaveSuccess && attackInfo.onSaveSuccess !== "none" && ( + <> ({attackInfo.onSaveSuccess} on success) + )} + +
+ )} + + {/* Damage */} + {attackInfo.damage && attackInfo.damage.length > 0 && ( +
+ Damage: +
    + {attackInfo.damage.map((dmg) => ( +
  • + {dmg.formula} + {dmg.type} + {dmg.notes && ({dmg.notes})} +
  • + ))} +
+ {attackInfo.scalingExplanation && ( +
+ + {attackInfo.scalingExplanation} +
+ )} +
+ )} + + {/* Healing */} + {attackInfo.healing && ( +
+ Healing:{" "} + {attackInfo.healing} + hit points +
+ )} +
+
+ )} + {/* Spell details with accordion */} diff --git a/src/lib/spellFormatters.ts b/src/lib/spellFormatters.ts index ac2ea2c..43e4b64 100644 --- a/src/lib/spellFormatters.ts +++ b/src/lib/spellFormatters.ts @@ -129,3 +129,17 @@ export function formatAreaOfEffect(area: AreaOfEffect): string { return `${area.length}-foot-long, ${area.width}-foot-wide line${origin}` } } + +export function formatDamageFormula(dice?: Dice, flatBonus?: number): string { + const parts: string[] = [] + + if (dice && dice.length > 0) { + parts.push(formatDice(dice)) + } + + if (flatBonus) { + parts.push(flatBonus > 0 ? `+${flatBonus}` : `${flatBonus}`) + } + + return parts.join(" ") +} diff --git a/src/routes/character.tsx b/src/routes/character.tsx index d32eea4..59fda95 100644 --- a/src/routes/character.tsx +++ b/src/routes/character.tsx @@ -820,7 +820,11 @@ characterRoutes.post("/characters/:id/castspell", async (c) => { const updatedChar = (await computeCharacter(getDb(c), characterId))! return c.html( <> - + ) diff --git a/src/services/castSpell.ts b/src/services/castSpell.ts index 9ce812f..02e257e 100644 --- a/src/services/castSpell.ts +++ b/src/services/castSpell.ts @@ -1,8 +1,10 @@ -import type { SpellLevelType } from "@src/lib/dnd" +import type { AbilityType, SpellLevelType } from "@src/lib/dnd" +import type { DamageType, Spell } from "@src/lib/dnd/spells" import { spells } from "@src/lib/dnd/spells" import { zodToFormErrors } from "@src/lib/formErrors" import { Checkbox, NumberField, OptionalString } from "@src/lib/formSchemas" import type { ServiceResult } from "@src/lib/serviceResult" +import { formatDamageFormula } from "@src/lib/spellFormatters" import { tool } from "ai" import type { SQL } from "bun" import { z } from "zod" @@ -30,7 +32,123 @@ export const CastSpellApiSchema = z.object({ is_check: Checkbox().optional().default(false), }) -export type CastSpellResult = ServiceResult<{ note: string; spellId: string }> +export type DamageInfo = { + type: DamageType + formula: string + notes?: string +} + +export type AttackInfo = { + attackBonus?: number + saveDC?: number + saveAbility?: AbilityType + onSaveSuccess?: string + damage?: DamageInfo[] + healing?: string + scalingExplanation?: string +} + +export type CastSpellResult = ServiceResult<{ + note: string + spellId: string + attackInfo?: AttackInfo +}> + +/** + * Compute attack/damage information for a spell based on character stats + */ +function computeAttackInfo( + char: ComputedCharacter, + spell: Spell, + slotLevel: number | null +): AttackInfo | undefined { + // Only compute attack info if the spell has combat mechanics + if (spell.resolution.kind === "none" && !spell.damage && !spell.healingDice) { + return undefined + } + + const attackInfo: AttackInfo = {} + + // Get the appropriate spell info for this character + const spellInfo = char.spells.find( + (s) => + s.preparedSpells.some((ps) => ps.spell_id === spell.id) || + s.cantripSlots.some((cs) => cs.spell_id === spell.id) + ) + + // Attack spell - show attack bonus + if (spell.resolution.kind === "attack") { + attackInfo.attackBonus = spellInfo?.spellAttackBonus ?? 0 + } + + // Save spell - show DC and save ability + if (spell.resolution.kind === "save") { + attackInfo.saveDC = spellInfo?.spellSaveDC ?? 0 + attackInfo.saveAbility = spell.resolution.ability + attackInfo.onSaveSuccess = spell.resolution.onSuccess || "none" + } + + // Calculate damage (with scaling if applicable) + if (spell.damage && spell.damage.length > 0) { + const effectiveLevel = slotLevel || spell.level + const damageInfo: DamageInfo[] = [] + let scalingExplanation: string | undefined + + // Check if spell scales with slot level + if (spell.damageScaling?.mode === "perSlotLevel" && effectiveLevel > spell.level) { + const scaledDice = spell.damageScaling.progression[effectiveLevel] + if (scaledDice) { + // Use scaled damage + for (const damageEntry of spell.damage) { + const formula = formatDamageFormula(scaledDice, damageEntry.flatBonus) + damageInfo.push({ + type: damageEntry.type, + formula, + notes: damageEntry.notes, + }) + } + + // Build scaling explanation + const baseDice = spell.damage[0]?.dice + if (baseDice) { + const baseFormula = formatDamageFormula(baseDice) + const scaledFormula = formatDamageFormula(scaledDice) + const dicePerLevel = scaledDice.length - baseDice.length + scalingExplanation = `Base: ${baseFormula}, +${dicePerLevel}d${scaledDice[0]} per level above ${spell.level} = ${scaledFormula}` + } + } + } else { + // Use base damage (no scaling or cast at base level) + for (const damageEntry of spell.damage) { + const formula = formatDamageFormula(damageEntry.dice, damageEntry.flatBonus) + damageInfo.push({ + type: damageEntry.type, + formula, + notes: damageEntry.notes, + }) + } + } + + if (damageInfo.length > 0) { + attackInfo.damage = damageInfo + } + if (scalingExplanation) { + attackInfo.scalingExplanation = scalingExplanation + } + } + + // Calculate healing + if (spell.healingDice) { + attackInfo.healing = formatDamageFormula(spell.healingDice) + } + + // Only return attackInfo if it has meaningful data + if (Object.keys(attackInfo).length === 0) { + return undefined + } + + return attackInfo +} /** * Cast a spell, consuming a spell slot if not cast as a ritual @@ -141,11 +259,13 @@ export async function castSpell( // No spell slot used if (isCantrip || result.data.as_ritual) { + const attackInfo = computeAttackInfo(char, spell, null) return { complete: true, result: { note: `You cast ${spell.name}${asRitual ? " as a ritual" : ""}. No spell slot was used.`, spellId: spell.id, + attackInfo, }, } } else if (!result.data.slot_level) { @@ -175,11 +295,13 @@ export async function castSpell( throw new Error("Failed to use spell slot") } + const attackInfo = computeAttackInfo(char, spell, result.data.slot_level) return { complete: true, result: { note: `You cast ${spell.name} using a level ${result.data.slot_level} spell slot.`, spellId: spell.id, + attackInfo, }, } } @@ -188,7 +310,15 @@ export async function castSpell( export const castSpellToolName = "cast_spell" as const export const castSpellTool = tool({ name: castSpellToolName, - description: `Cast a spell. Requires a spell id, which you must *always* get beforehand using lookup_spell tool. If casting a cantrip or casting as a ritual, this requires using a spell slot. Feel free to assume a spell slot level when appropriate.`, + description: `Cast a spell. Requires a spell id, which you must *always* get beforehand using lookup_spell tool. If casting a cantrip or casting as a ritual, this *does not* require using a spell slot. Feel free to assume a spell slot level if the spell has a minimum or if the character only has certain slots available. + +IMPORTANT: After casting an attack or damage spell, always inform the user of the combat results. Include: +- For attack spells: The attack bonus (e.g., "+7 to hit with a ranged spell attack") +- For save spells: The save DC and ability (e.g., "DC 15 Dexterity saving throw, half damage on success") +- For damage spells: The damage dice formula (e.g., "8d6 fire damage" or "3d8+5 lightning damage") +- For scaled spells: Mention the scaling (e.g., "Fireball cast at 5th level deals 10d6 fire damage") + +Format example: "You cast Fireball at 3rd level. Targets must make a DC 15 Dexterity saving throw, taking 8d6 fire damage on a failed save or half on a success."`, inputSchema: CastSpellApiSchema.omit({ note: true, is_check: true }).extend({ note: z.string().optional().describe("Optional additional notes about the casting"), }), From 778e6315e7ffb21f8e749984c27b0582691bf214 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Tue, 11 Nov 2025 21:58:11 -0800 Subject: [PATCH 3/9] fix: correct location for htmx-auth file --- {public/static => static}/htmx-auth.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {public/static => static}/htmx-auth.js (100%) diff --git a/public/static/htmx-auth.js b/static/htmx-auth.js similarity index 100% rename from public/static/htmx-auth.js rename to static/htmx-auth.js From da007a8c6d862050070257fc772f226a2bf134a8 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Tue, 11 Nov 2025 21:58:11 -0800 Subject: [PATCH 4/9] feat: initial stab at multiple avatars we do the db work and some initial UI work, but the UI doesn't fully function --- .gitignore | 1 + bun.lock | 25 ++ db/schema.sql | 76 +++++- ...0251112060352_create_character_avatars.sql | 22 ++ ...0251112060400_migrate_existing_avatars.sql | 20 ++ ...60500_remove_avatar_id_from_characters.sql | 5 + package.json | 3 +- src/components/AvatarCropper.tsx | 68 ++++++ src/components/AvatarDisplay.tsx | 95 ++++++++ src/components/AvatarGallery.tsx | 96 ++++++++ src/components/CharacterInfo.tsx | 30 +-- src/components/Layout.tsx | 2 + src/components/UpdateAvatarForm.tsx | 18 +- src/db/character_avatars.ts | 222 ++++++++++++++++++ src/db/characters.ts | 1 - src/routes/character.tsx | 202 ++++++++++++++++ src/services/computeCharacter.ts | 21 ++ src/services/createCharacter.ts | 1 - src/services/importCharacter.ts | 1 - src/services/listCharacters.ts | 1 - src/services/updateAvatar.ts | 23 +- src/test/factories/character.ts | 1 - static/avatar-cropper.js | 90 +++++++ static/styles.css | 6 +- 24 files changed, 964 insertions(+), 66 deletions(-) create mode 100644 migrations/20251112060352_create_character_avatars.sql create mode 100644 migrations/20251112060400_migrate_existing_avatars.sql create mode 100644 migrations/20251112060500_remove_avatar_id_from_characters.sql create mode 100644 src/components/AvatarCropper.tsx create mode 100644 src/components/AvatarDisplay.tsx create mode 100644 src/components/AvatarGallery.tsx create mode 100644 src/db/character_avatars.ts create mode 100644 static/avatar-cropper.js diff --git a/.gitignore b/.gitignore index 65ee0c1..5abf482 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ static/htmx.min.js static/htmx-ext-sse.js static/idiomorph-ext.min.js +static/cropper.min.js # output out diff --git a/bun.lock b/bun.lock index f3df0ab..439a173 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@types/marked": "^6.0.0", "ai": "^5.0.82", "clsx": "^2.1.1", + "cropperjs": "^2.1.0", "hono": "^4.9.6", "htmx-ext-sse": "^2.2.4", "htmx.org": "^2.0.8", @@ -148,6 +149,28 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="], + "@cropper/element": ["@cropper/element@2.1.0", "", { "dependencies": { "@cropper/utils": "^2.1.0" } }, "sha512-2zELddqHQNmlvkPoiYzE5nxEjPE+C8nXoTPuvV3FvLp3YjBinc7qb73Icg9UXP0o9qC4+h9q96JgGo0AyMO/Ng=="], + + "@cropper/element-canvas": ["@cropper/element-canvas@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-el+rfJpZxsD2q5XxDBA4fRczcrOqB65Lb7roqXOq8LKufwf4bPWA9C6DjNJJahh/TP94dsLIEy3tSkgRMDv3Aw=="], + + "@cropper/element-crosshair": ["@cropper/element-crosshair@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-0V589dAx8uZAfvJwdINLn76gfPQEafPH94ukjJ76uX0FCUovLaAVX+VRD/MDSYn0Mza/xejzmL9Dhd1DfemvmA=="], + + "@cropper/element-grid": ["@cropper/element-grid@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-dEnk0rO+vp553LMvsPYgfrqVFcYXeVFrgFeavBYYEhAXtO40p7kN4rmLYLMMjaN+T/Mx2BATv6kUQpALKy2HLw=="], + + "@cropper/element-handle": ["@cropper/element-handle@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-8BklWA4C/2GGAULupIWleSnGutECvYt3vx9flodqDfZpDEozws4LgLqmmzVuQmVkRVUdLnXdtx28kjgWLtzkHg=="], + + "@cropper/element-image": ["@cropper/element-image@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/element-canvas": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-mXOV8ixJvG0XtTxLebYAKDjEkFbFOQnsF02hXPZk1yQSV0J+LLhN7a2NePrtKnoTsEV19fhhX3UorMoyGGxvzg=="], + + "@cropper/element-selection": ["@cropper/element-selection@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/element-canvas": "^2.1.0", "@cropper/element-image": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-mtFtBl6HIa/s9TWohXw+Z5eJoeYTqylrIcHvS7oVv0uM7IyeRwBW65Q7z+KtLfq/LW+2Sw/XDyvR+VN/DawBPw=="], + + "@cropper/element-shade": ["@cropper/element-shade@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/element-canvas": "^2.1.0", "@cropper/element-selection": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-zMdyqbb0lc0Vd1oj2Z1miIZvhyZG41OXMHvrNt0hNwblh0dVdrvtw48lnFDgRv+672vt2CNx7Q04GuvCQfPlgg=="], + + "@cropper/element-viewer": ["@cropper/element-viewer@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/element-canvas": "^2.1.0", "@cropper/element-image": "^2.1.0", "@cropper/element-selection": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-XnxlQuqHitd1FOFZ6E0yXAF5NYd/LyIvONLLHI9p1rJw747WYKUPxQaSYtFKF7IOizJu/8mMj++Zc1dZ5ZP3YQ=="], + + "@cropper/elements": ["@cropper/elements@2.1.0", "", { "dependencies": { "@cropper/element": "^2.1.0", "@cropper/element-canvas": "^2.1.0", "@cropper/element-crosshair": "^2.1.0", "@cropper/element-grid": "^2.1.0", "@cropper/element-handle": "^2.1.0", "@cropper/element-image": "^2.1.0", "@cropper/element-selection": "^2.1.0", "@cropper/element-shade": "^2.1.0", "@cropper/element-viewer": "^2.1.0" } }, "sha512-qvzlYDn3VQgPPpsCu6Gi1XUO0v3vpXQFSjjxcVijbXeNsl/eiKrN7H9/CEiRgi5vr8kXfd7ZvgYxBjUBbH+y+w=="], + + "@cropper/utils": ["@cropper/utils@2.1.0", "", {}, "sha512-wLtpZ4/UWgo+fGmG8NBWge8x5ehjfDe9ovleDfLy8kpwFaw43XXOEXQtRL1UNr0u4JZxaeO8FcXcolRWUUrlRQ=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], "@faker-js/faker": ["@faker-js/faker@10.1.0", "", {}, "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg=="], @@ -496,6 +519,8 @@ "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], + "cropperjs": ["cropperjs@2.1.0", "", { "dependencies": { "@cropper/elements": "^2.1.0", "@cropper/utils": "^2.1.0" } }, "sha512-SsSDqdVRl+mjbIBkGWlk1gCGcc+HzBqCbH5EQ+1tkAFUdxq2KUGukXF1RqhmvXrrdrX7PDwSUkWgXS7E36KvGQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], diff --git a/db/schema.sql b/db/schema.sql index 695bdb0..a57fa50 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict Ah1gzcmwaXniQw2dzIyVlrPF4EVIv28DgaOjh5TDOwpcDPd3dZKeX8QreYsxoVW +\restrict Dg0ksc8IIsh0chvkrHOQy7LtnwsmjF3WyModqIXmKLfbi99THWiNyWMb9U47Fk5 -- Dumped from database version 16.10 -- Dumped by pg_dump version 18.0 @@ -256,6 +256,28 @@ CREATE TABLE public.char_traits ( ); +-- +-- Name: character_avatars; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.character_avatars ( + id text NOT NULL, + character_id text NOT NULL, + upload_id text NOT NULL, + is_primary boolean DEFAULT false NOT NULL, + crop_x_percent real, + crop_y_percent real, + crop_width_percent real, + crop_height_percent real, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT character_avatars_crop_height_percent_check CHECK (((crop_height_percent IS NULL) OR ((crop_height_percent >= (0)::double precision) AND (crop_height_percent <= (1)::double precision)))), + CONSTRAINT character_avatars_crop_width_percent_check CHECK (((crop_width_percent IS NULL) OR ((crop_width_percent >= (0)::double precision) AND (crop_width_percent <= (1)::double precision)))), + CONSTRAINT character_avatars_crop_x_percent_check CHECK (((crop_x_percent IS NULL) OR ((crop_x_percent >= (0)::double precision) AND (crop_x_percent <= (1)::double precision)))), + CONSTRAINT character_avatars_crop_y_percent_check CHECK (((crop_y_percent IS NULL) OR ((crop_y_percent >= (0)::double precision) AND (crop_y_percent <= (1)::double precision)))) +); + + -- -- Name: characters; Type: TABLE; Schema: public; Owner: - -- @@ -271,7 +293,6 @@ CREATE TABLE public.characters ( created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, ruleset text DEFAULT 'srd51'::text NOT NULL, - avatar_id text, archived_at timestamp with time zone ); @@ -530,6 +551,22 @@ ALTER TABLE ONLY public.char_traits ADD CONSTRAINT char_traits_pkey PRIMARY KEY (id); +-- +-- Name: character_avatars character_avatars_character_id_upload_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.character_avatars + ADD CONSTRAINT character_avatars_character_id_upload_id_key UNIQUE (character_id, upload_id); + + +-- +-- Name: character_avatars character_avatars_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.character_avatars + ADD CONSTRAINT character_avatars_pkey PRIMARY KEY (id); + + -- -- Name: characters characters_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -728,6 +765,20 @@ CREATE INDEX idx_char_spells_prepared_spell_id ON public.char_spells_prepared US CREATE INDEX idx_char_traits_char_id ON public.char_traits USING btree (character_id, created_at); +-- +-- Name: idx_character_avatars_character_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_character_avatars_character_id ON public.character_avatars USING btree (character_id); + + +-- +-- Name: idx_character_avatars_is_primary; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_character_avatars_is_primary ON public.character_avatars USING btree (character_id, is_primary) WHERE (is_primary = true); + + -- -- Name: idx_characters_user_id; Type: INDEX; Schema: public; Owner: - -- @@ -1037,11 +1088,19 @@ ALTER TABLE ONLY public.char_traits -- --- Name: characters characters_avatar_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- Name: character_avatars character_avatars_character_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.characters - ADD CONSTRAINT characters_avatar_id_fkey FOREIGN KEY (avatar_id) REFERENCES public.uploads(id); +ALTER TABLE ONLY public.character_avatars + ADD CONSTRAINT character_avatars_character_id_fkey FOREIGN KEY (character_id) REFERENCES public.characters(id) ON DELETE CASCADE; + + +-- +-- Name: character_avatars character_avatars_upload_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.character_avatars + ADD CONSTRAINT character_avatars_upload_id_fkey FOREIGN KEY (upload_id) REFERENCES public.uploads(id) ON DELETE CASCADE; -- @@ -1104,7 +1163,7 @@ ALTER TABLE ONLY public.items -- PostgreSQL database dump complete -- -\unrestrict Ah1gzcmwaXniQw2dzIyVlrPF4EVIv28DgaOjh5TDOwpcDPd3dZKeX8QreYsxoVW +\unrestrict Dg0ksc8IIsh0chvkrHOQy7LtnwsmjF3WyModqIXmKLfbi99THWiNyWMb9U47Fk5 -- @@ -1144,4 +1203,7 @@ INSERT INTO public.schema_migrations (version) VALUES ('20251030232843'), ('20251111071524'), ('20251111085621'), - ('20251111221748'); + ('20251111221748'), + ('20251112060352'), + ('20251112060400'), + ('20251112060500'); diff --git a/migrations/20251112060352_create_character_avatars.sql b/migrations/20251112060352_create_character_avatars.sql new file mode 100644 index 0000000..2a59c46 --- /dev/null +++ b/migrations/20251112060352_create_character_avatars.sql @@ -0,0 +1,22 @@ +-- migrate:up +CREATE TABLE character_avatars ( + id TEXT PRIMARY KEY, + character_id TEXT NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + crop_x_percent REAL CHECK (crop_x_percent IS NULL OR (crop_x_percent >= 0 AND crop_x_percent <= 1)), + crop_y_percent REAL CHECK (crop_y_percent IS NULL OR (crop_y_percent >= 0 AND crop_y_percent <= 1)), + crop_width_percent REAL CHECK (crop_width_percent IS NULL OR (crop_width_percent >= 0 AND crop_width_percent <= 1)), + crop_height_percent REAL CHECK (crop_height_percent IS NULL OR (crop_height_percent >= 0 AND crop_height_percent <= 1)), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(character_id, upload_id) +); + +CREATE INDEX idx_character_avatars_character_id ON character_avatars(character_id); +CREATE INDEX idx_character_avatars_is_primary ON character_avatars(character_id, is_primary) WHERE is_primary = TRUE; + +-- migrate:down +DROP INDEX IF EXISTS idx_character_avatars_is_primary; +DROP INDEX IF EXISTS idx_character_avatars_character_id; +DROP TABLE IF EXISTS character_avatars; diff --git a/migrations/20251112060400_migrate_existing_avatars.sql b/migrations/20251112060400_migrate_existing_avatars.sql new file mode 100644 index 0000000..cc7f423 --- /dev/null +++ b/migrations/20251112060400_migrate_existing_avatars.sql @@ -0,0 +1,20 @@ +-- migrate:up +-- Migrate existing avatar_id from characters table to character_avatars table +-- Using md5 hash of character_id + timestamp to generate unique IDs (ULID-like) +INSERT INTO character_avatars (id, character_id, upload_id, is_primary, created_at, updated_at) +SELECT + SUBSTRING(MD5(id || EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::TEXT) FROM 1 FOR 26) as id, + id as character_id, + avatar_id as upload_id, + TRUE as is_primary, + CURRENT_TIMESTAMP as created_at, + CURRENT_TIMESTAMP as updated_at +FROM characters +WHERE avatar_id IS NOT NULL; + +-- migrate:down +-- Remove migrated avatars +DELETE FROM character_avatars +WHERE character_id IN ( + SELECT id FROM characters WHERE avatar_id IS NOT NULL +); diff --git a/migrations/20251112060500_remove_avatar_id_from_characters.sql b/migrations/20251112060500_remove_avatar_id_from_characters.sql new file mode 100644 index 0000000..e280ca8 --- /dev/null +++ b/migrations/20251112060500_remove_avatar_id_from_characters.sql @@ -0,0 +1,5 @@ +-- migrate:up +ALTER TABLE characters DROP COLUMN avatar_id; + +-- migrate:down +ALTER TABLE characters ADD COLUMN avatar_id TEXT REFERENCES uploads(id); diff --git a/package.json b/package.json index 563b1df..48e267f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "type": "module", "private": true, "scripts": { - "postinstall": "mkdir -p static && cp node_modules/htmx.org/dist/htmx.min.js static/ && cp node_modules/htmx-ext-sse/sse.js static/htmx-ext-sse.js && cp node_modules/idiomorph/dist/idiomorph-ext.min.js static/idiomorph-ext.min.js" + "postinstall": "mkdir -p static && cp node_modules/htmx.org/dist/htmx.min.js static/ && cp node_modules/htmx-ext-sse/sse.js static/htmx-ext-sse.js && cp node_modules/idiomorph/dist/idiomorph-ext.min.js static/idiomorph-ext.min.js && cp node_modules/cropperjs/dist/cropper.min.js static/" }, "devDependencies": { "@biomejs/biome": "^2.2.6", @@ -37,6 +37,7 @@ "@types/marked": "^6.0.0", "ai": "^5.0.82", "clsx": "^2.1.1", + "cropperjs": "^2.1.0", "hono": "^4.9.6", "htmx-ext-sse": "^2.2.4", "htmx.org": "^2.0.8", diff --git a/src/components/AvatarCropper.tsx b/src/components/AvatarCropper.tsx new file mode 100644 index 0000000..c36d0db --- /dev/null +++ b/src/components/AvatarCropper.tsx @@ -0,0 +1,68 @@ +import { ModalContent } from "./ui/ModalContent" + +export interface AvatarCropperProps { + characterId: string + avatarId?: string + uploadUrl: string + existingCrop?: { + x: number + y: number + width: number + height: number + } | null + isNewUpload?: boolean +} + +export const AvatarCropper = ({ + characterId, + avatarId, + uploadUrl, + existingCrop, + isNewUpload = false, +}: AvatarCropperProps) => { + const action = avatarId + ? `/characters/${characterId}/avatars/${avatarId}/crop` + : `/characters/${characterId}/avatars` + + return ( + + + + + + ) +} diff --git a/src/components/AvatarDisplay.tsx b/src/components/AvatarDisplay.tsx new file mode 100644 index 0000000..3181592 --- /dev/null +++ b/src/components/AvatarDisplay.tsx @@ -0,0 +1,95 @@ +import type { CropPercents } from "@src/db/character_avatars" +import type { ComputedCharacter } from "@src/services/computeCharacter" + +export interface AvatarDisplayProps { + // Clickable mode: pass character to render as button with gallery link + character?: ComputedCharacter + + // Display-only mode: pass these directly + uploadUrl?: string + cropPercents?: CropPercents | null + alt?: string + className?: string +} + +/** + * Calculate CSS object-position from crop percentages + * The object-position centers the crop area within the visible container + */ +function getCropStyle(cropPercents?: CropPercents | null) { + if (!cropPercents) { + return { + objectFit: "cover" as const, + objectPosition: "50% 50%", + } + } + + // Calculate the center point of the crop area + const centerX = (cropPercents.crop_x_percent + cropPercents.crop_width_percent / 2) * 100 + const centerY = (cropPercents.crop_y_percent + cropPercents.crop_height_percent / 2) * 100 + + return { + objectFit: "cover" as const, + objectPosition: `${centerX}% ${centerY}%`, + } +} + +/** + * Display an avatar image with optional soft-crop using CSS + * Supports two modes: + * - Clickable: Pass character prop to render as button with gallery modal trigger + * - Display-only: Pass uploadUrl/cropPercents to render just the image + */ +export const AvatarDisplay = ({ + character, + uploadUrl, + cropPercents, + alt = "Avatar", + className = "", +}: AvatarDisplayProps) => { + // Clickable mode: render as button with modal trigger and hover effects + if (character) { + const imgUrl = character.avatar_id ? `/uploads/${character.avatar_id}` : "/static/placeholder.png" + const cropStyle = getCropStyle(character.avatar_crop) + const imgAlt = `${character.name}'s avatar` + + return ( + + ) + } + + // Display-only mode: render just the image + const cropStyle = getCropStyle(cropPercents) + const imgUrl = uploadUrl || "/static/placeholder.png" + + return ( +
+ {alt} +
+ ) +} diff --git a/src/components/AvatarGallery.tsx b/src/components/AvatarGallery.tsx new file mode 100644 index 0000000..998e9b1 --- /dev/null +++ b/src/components/AvatarGallery.tsx @@ -0,0 +1,96 @@ +import type { CharacterAvatar } from "@src/db/character_avatars" +import { AvatarDisplay } from "./AvatarDisplay" +import { ModalContent } from "./ui/ModalContent" + +export interface AvatarGalleryProps { + characterId: string + avatars: Array +} + +export const AvatarGallery = ({ characterId, avatars }: AvatarGalleryProps) => { + const hasAvatars = avatars.length > 0 + + return ( + + + + + + ) +} diff --git a/src/components/CharacterInfo.tsx b/src/components/CharacterInfo.tsx index 33f3bbd..834ec96 100644 --- a/src/components/CharacterInfo.tsx +++ b/src/components/CharacterInfo.tsx @@ -1,6 +1,7 @@ import { getEffectTooltip, hasEffect } from "@src/lib/effectTooltip" import type { ComputedCharacter } from "@src/services/computeCharacter" import type { EquippedComputedItem } from "@src/services/computeCharacterItems" +import { AvatarDisplay } from "./AvatarDisplay" import { HitDiceDisplay } from "./ui/HitDiceDisplay" import { HitPointsBar } from "./ui/HitPointsBar" import { LabeledValue } from "./ui/LabeledValue" @@ -89,34 +90,7 @@ export const CharacterInfo = ({ character, swapOob }: CharacterInfoProps) => {
- +

{character.name}

diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 88e969e..af83242 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -84,6 +84,7 @@ export const Layout = ({ + @@ -130,6 +131,7 @@ export const Layout = ({ /> + ) diff --git a/src/components/UpdateAvatarForm.tsx b/src/components/UpdateAvatarForm.tsx index 05b6417..bfe6ec2 100644 --- a/src/components/UpdateAvatarForm.tsx +++ b/src/components/UpdateAvatarForm.tsx @@ -6,23 +6,9 @@ export interface UpdateAvatarFormProps { errors?: Record } -export const UpdateAvatarForm = ({ character, errors }: UpdateAvatarFormProps) => ( - +export const UpdateAvatarForm = ({ errors }: UpdateAvatarFormProps) => ( +