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/pulumi/infra/index.ts b/pulumi/infra/index.ts
index 56e45db..10251fe 100644
--- a/pulumi/infra/index.ts
+++ b/pulumi/infra/index.ts
@@ -159,6 +159,15 @@ 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,
diff --git a/src/components/AvatarCropper.tsx b/src/components/AvatarCropper.tsx
new file mode 100644
index 0000000..3cc19d1
--- /dev/null
+++ b/src/components/AvatarCropper.tsx
@@ -0,0 +1,101 @@
+import type { ComputedCharacter } from "@src/services/computeCharacter"
+import { ModalContent } from "./ui/ModalContent"
+
+export interface AvatarCropperProps {
+ character: ComputedCharacter
+ avatarIndex: number
+}
+
+export const AvatarCropper = ({ character, avatarIndex }: AvatarCropperProps) => {
+ const avatar = character.avatars[avatarIndex]
+ if (!avatar) {
+ throw new Error(`Avatar at index ${avatarIndex} not found`)
+ }
+
+ const uploadUrl = avatar.uploadUrl
+
+ const existingCrop = {
+ x: avatar.crop_x_percent,
+ y: avatar.crop_y_percent!,
+ width: avatar.crop_width_percent!,
+ height: avatar.crop_height_percent!,
+ }
+
+ const action = `/characters/${character.id}/avatars/${avatar.id}/crop`
+
+ return (
+
+
+ {/* Cropper.js v2 web components */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Form for submitting crop data */}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/AvatarDisplay.tsx b/src/components/AvatarDisplay.tsx
new file mode 100644
index 0000000..d82fa8a
--- /dev/null
+++ b/src/components/AvatarDisplay.tsx
@@ -0,0 +1,147 @@
+import type { CropPercents } from "@src/db/character_avatars"
+import type { CharacterAvatarWithUrl, ComputedCharacter } from "@src/services/computeCharacter"
+
+export interface AvatarDisplayProps {
+ character: ComputedCharacter
+ avatarIndex?: number
+ mode: "clickable-gallery" | "clickable-lightbox" | "display-only"
+ className?: string
+}
+
+/**
+ * Generate CSS custom properties for crop transform
+ * Uses translate + scale to display only the cropped region
+ */
+function getCropStyle(cropPercents?: CropPercents | null) {
+ if (!cropPercents) {
+ // No crop - just center the image
+ return {
+ containerStyle: "",
+ imageStyle: "width: 100%; height: 100%; object-fit: cover; object-position: 50% 50%;",
+ }
+ }
+
+ // CSS custom properties for the transform
+ const containerStyle = `
+ --crop-x: ${cropPercents.crop_x_percent};
+ --crop-y: ${cropPercents.crop_y_percent};
+ --crop-w: ${cropPercents.crop_width_percent};
+ --crop-h: ${cropPercents.crop_height_percent};
+ `.trim()
+
+ // Transform to show only the cropped region
+ // Scale up the image so crop region fills container, then translate to position it
+ // translate percentages are relative to the image's own dimensions
+ const imageStyle = `
+ width: 100%;
+ height: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform-origin: top left;
+ transform:
+ translate(
+ calc(-1 * var(--crop-x) / var(--crop-w) * 100%),
+ calc(-1 * var(--crop-y) / var(--crop-w) * 100%)
+ )
+ scale(calc(1 / var(--crop-w)));
+ `.trim()
+
+ return { containerStyle, imageStyle }
+}
+
+/**
+ * Display an avatar image with optional soft-crop using CSS
+ * Supports three modes:
+ * - clickable-gallery: Button that opens avatar gallery modal
+ * - clickable-lightbox: Button that opens lightbox at specific avatar index
+ * - display-only: Just renders the image
+ */
+export const AvatarDisplay = ({
+ character,
+ avatarIndex,
+ mode,
+ className = "",
+}: AvatarDisplayProps) => {
+ // For clickable-gallery mode, use primary avatar
+ // For other modes, use specified index or primary
+ let avatar: CharacterAvatarWithUrl | undefined
+ if (mode === "clickable-gallery") {
+ avatar = character.avatars.find((a) => a.is_primary) || character.avatars[0]
+ } else {
+ const index = avatarIndex ?? 0
+ avatar = character.avatars[index]
+ }
+
+ const imgUrl = avatar?.uploadUrl || "/static/placeholder.png"
+ const cropPercents =
+ avatar &&
+ avatar.crop_x_percent !== null &&
+ avatar.crop_y_percent !== null &&
+ avatar.crop_width_percent !== null &&
+ avatar.crop_height_percent !== null
+ ? {
+ crop_x_percent: avatar.crop_x_percent,
+ crop_y_percent: avatar.crop_y_percent,
+ crop_width_percent: avatar.crop_width_percent,
+ crop_height_percent: avatar.crop_height_percent,
+ }
+ : null
+ const cropStyle = getCropStyle(cropPercents)
+ const imgAlt = `${character.name}'s avatar`
+
+ // Clickable-gallery mode: Opens gallery modal
+ if (mode === "clickable-gallery") {
+ return (
+
+ )
+ }
+
+ // Clickable-lightbox mode: Opens lightbox at specific index
+ if (mode === "clickable-lightbox") {
+ return (
+
+ )
+ }
+
+ // Display-only mode: Just render the image
+ return (
+
+

+
+ )
+}
diff --git a/src/components/AvatarGallery.tsx b/src/components/AvatarGallery.tsx
new file mode 100644
index 0000000..83a8274
--- /dev/null
+++ b/src/components/AvatarGallery.tsx
@@ -0,0 +1,101 @@
+import type { ComputedCharacter } from "@src/services/computeCharacter"
+import { AvatarDisplay } from "./AvatarDisplay"
+import { ModalContent } from "./ui/ModalContent"
+
+export interface AvatarGalleryProps {
+ character: ComputedCharacter
+}
+
+export const AvatarGallery = ({ character }: AvatarGalleryProps) => {
+ const hasAvatars = character.avatars.length > 0
+
+ return (
+
+
+ {!hasAvatars && (
+
+
No avatars uploaded yet.
+
+ )}
+
+ {hasAvatars && (
+
+ {character.avatars.map((avatar, index) => (
+
+
+
+ {/* Avatar preview - click to open lightbox */}
+
+
+ {/* Action buttons */}
+
+ {/* Primary button/badge */}
+ {avatar.is_primary ? (
+
+ Primary
+
+ ) : (
+
+ )}
+
+ {/* Crop button */}
+
+
+ {/* Delete button */}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/AvatarLightbox.tsx b/src/components/AvatarLightbox.tsx
new file mode 100644
index 0000000..86a3fc6
--- /dev/null
+++ b/src/components/AvatarLightbox.tsx
@@ -0,0 +1,71 @@
+import type { ComputedCharacter } from "@src/services/computeCharacter"
+import { AvatarDisplay } from "./AvatarDisplay"
+import { ModalContent } from "./ui/ModalContent"
+
+export interface AvatarLightboxProps {
+ character: ComputedCharacter
+ currentIndex: number
+}
+
+export const AvatarLightbox = ({ character, currentIndex }: AvatarLightboxProps) => {
+ const totalAvatars = character.avatars.length
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : totalAvatars - 1
+ const nextIndex = currentIndex < totalAvatars - 1 ? currentIndex + 1 : 0
+ const showNavigation = totalAvatars > 1
+
+ return (
+
+
+ {/* Main avatar display */}
+
+
+ {/* Navigation arrows */}
+ {showNavigation && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/CharacterInfo.tsx b/src/components/CharacterInfo.tsx
index 33f3bbd..8b2744a 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..d973a81 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -84,6 +84,7 @@ export const Layout = ({
+
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 && (
+
+
+
+ {/* 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/components/UpdateAvatarForm.tsx b/src/components/UploadAvatarForm.tsx
similarity index 70%
rename from src/components/UpdateAvatarForm.tsx
rename to src/components/UploadAvatarForm.tsx
index 05b6417..6ff36de 100644
--- a/src/components/UpdateAvatarForm.tsx
+++ b/src/components/UploadAvatarForm.tsx
@@ -1,28 +1,14 @@
import type { Character } from "@src/db/characters"
import { ModalContent } from "./ui/ModalContent"
-export interface UpdateAvatarFormProps {
+export interface UploadAvatarFormProps {
character: Character
errors?: Record
}
-export const UpdateAvatarForm = ({ character, errors }: UpdateAvatarFormProps) => (
-
+export const UploadAvatarForm = ({ character, errors }: UploadAvatarFormProps) => (
+
- {/* Current Avatar Preview */}
-
-
-

-
-
-