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 ( + + + + + + + + ) +} 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 ( +
+ {imgAlt} +
+ ) +} 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 ( + + + + + + ) +} 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 ( + + + + + + ) +} 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 && ( +
+
+ + 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/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) => ( +