Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 69 additions & 7 deletions db/schema.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
\restrict Ah1gzcmwaXniQw2dzIyVlrPF4EVIv28DgaOjh5TDOwpcDPd3dZKeX8QreYsxoVW
\restrict Dg0ksc8IIsh0chvkrHOQy7LtnwsmjF3WyModqIXmKLfbi99THWiNyWMb9U47Fk5

-- Dumped from database version 16.10
-- Dumped by pg_dump version 18.0
Expand Down Expand Up @@ -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: -
--
Expand All @@ -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
);

Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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;


--
Expand Down Expand Up @@ -1104,7 +1163,7 @@ ALTER TABLE ONLY public.items
-- PostgreSQL database dump complete
--

\unrestrict Ah1gzcmwaXniQw2dzIyVlrPF4EVIv28DgaOjh5TDOwpcDPd3dZKeX8QreYsxoVW
\unrestrict Dg0ksc8IIsh0chvkrHOQy7LtnwsmjF3WyModqIXmKLfbi99THWiNyWMb9U47Fk5


--
Expand Down Expand Up @@ -1144,4 +1203,7 @@ INSERT INTO public.schema_migrations (version) VALUES
('20251030232843'),
('20251111071524'),
('20251111085621'),
('20251111221748');
('20251111221748'),
('20251112060352'),
('20251112060400'),
('20251112060500');
22 changes: 22 additions & 0 deletions migrations/20251112060352_create_character_avatars.sql
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +12 to +13
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The UNIQUE constraint on (character_id, upload_id) prevents the same upload from being used multiple times for the same character, even with different crop settings. This might be intentional, but it limits flexibility. Consider whether users should be able to create multiple avatars from the same upload with different crops.

Suggested change
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(character_id, upload_id)
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

Copilot uses AI. Check for mistakes.
);

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;
20 changes: 20 additions & 0 deletions migrations/20251112060400_migrate_existing_avatars.sql
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +2 to +6
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ID generation using SUBSTRING(MD5(...)) in the migration may not guarantee uniqueness across all records, especially if multiple characters are migrated in quick succession. While unlikely to collide in practice, consider using a proper ULID generation function or UUID if available in PostgreSQL to ensure uniqueness.

Suggested change
-- 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,
-- Ensure pgcrypto extension is enabled for gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- 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
gen_random_uuid() as id,

Copilot uses AI. Check for mistakes.
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
);
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions pulumi/infra/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 101 additions & 0 deletions src/components/AvatarCropper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ModalContent title="Adjust Crop">
<div class="modal-body">
{/* Cropper.js v2 web components */}
<div class="mb-3" style="max-width: 100%;">
<cropper-canvas style="height: 500px; width: 100%;">
<cropper-image src={uploadUrl} alt="Avatar to crop" scalable translatable />

<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection
aspectRatio="1"
movable
resizable
zoomable
data-existingx={existingCrop.x || undefined}
data-existingy={existingCrop.y || undefined}
data-existingw={existingCrop.width || undefined}
data-existingh={existingCrop.height || undefined}
>
<cropper-grid role="grid" covered></cropper-grid>
<cropper-crosshair centered></cropper-crosshair>
<cropper-handle
action="move"
theme-color="rgba(255, 255, 255, 0.35)"
></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
</div>

{/* Form for submitting crop data */}
<form id="cropForm" hx-post={action} hx-target="#character-info" hx-swap="outerHTML">
{/* Hidden fields for crop percentages */}
<input type="hidden" id="crop_x_percent" name="crop_x_percent" />
<input type="hidden" id="crop_y_percent" name="crop_y_percent" />
<input type="hidden" id="crop_width_percent" name="crop_width_percent" />
<input type="hidden" id="crop_height_percent" name="crop_height_percent" />

<div id="cropError" class="alert alert-danger d-none" role="alert"></div>
</form>
</div>

<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
hx-get={`/characters/${character.id}/avatars`}
hx-target="#editModalContent"
hx-swap="innerHTML"
>
<i class="bi bi-grid-3x3"></i> Back to Gallery
</button>
<button
type="button"
class="btn btn-primary"
hx-post={action}
hx-include="#cropForm"
hx-target="#editModalContent"
hx-swap="innerHTML"
>
Save Crop
</button>
</div>

<script src="/static/avatar-cropper.js"></script>
</ModalContent>
)
}
Loading