diff --git a/drizzle/0019_works_position.sql b/drizzle/0019_works_position.sql new file mode 100644 index 0000000..a86794e --- /dev/null +++ b/drizzle/0019_works_position.sql @@ -0,0 +1,14 @@ +DROP INDEX "works_user_id_created_at_idx";--> statement-breakpoint +ALTER TABLE "works" ADD COLUMN "position" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +-- #66: existing works keep the order they already had. The default of 0 would +-- do that on its own, since created_at still breaks the tie — this makes the +-- column say it rather than leave every row claiming to be first. +UPDATE "works" AS w +SET "position" = ordered.rank +FROM ( + SELECT "id", + row_number() OVER (PARTITION BY "user_id" ORDER BY "created_at", "id") - 1 AS rank + FROM "works" +) AS ordered +WHERE w."id" = ordered."id";--> statement-breakpoint +CREATE INDEX "works_user_id_position_idx" ON "works" USING btree ("user_id","position","created_at"); diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..9177650 --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,1571 @@ +{ + "id": "18c9c605-46e8-44d5-ab1b-c06c36d1e020", + "prevId": "7bc7dc86-9bd4-41cf-92c7-1f5768189222", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_issuer_account_id_unique": { + "name": "accounts_issuer_account_id_unique", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "file_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_file_id": { + "name": "parent_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_user_id_size_bytes_idx": { + "name": "files_user_id_size_bytes_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "size_bytes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_original_user_sha256_unique": { + "name": "files_original_user_sha256_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"parent_file_id\" IS NULL AND (\"files\".\"object_key\" IS NULL OR \"files\".\"object_key\" NOT LIKE '%/r360/%')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_variant_user_parent_kind_unique": { + "name": "files_variant_user_parent_kind_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"parent_file_id\" IS NOT NULL AND (\"files\".\"object_key\" IS NULL OR \"files\".\"object_key\" NOT LIKE '%/r360/%')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_r360_frame_user_object_key_unique": { + "name": "files_r360_frame_user_object_key_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"object_key\" LIKE '%/r360/%'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_parent_file_id_idx": { + "name": "files_parent_file_id_idx", + "columns": [ + { + "expression": "parent_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_object_key_idx": { + "name": "files_object_key_idx", + "columns": [ + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_user_id_users_id_fk": { + "name": "files_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "files_parent_file_id_files_id_fk": { + "name": "files_parent_file_id_files_id_fk", + "tableFrom": "files", + "tableTo": "files", + "columnsFrom": [ + "parent_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handle_redirects": { + "name": "handle_redirects", + "schema": "", + "columns": { + "old_handle": { + "name": "old_handle", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "handle_redirects_target_user_id_idx": { + "name": "handle_redirects_target_user_id_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "handle_redirects_target_user_id_users_id_fk": { + "name": "handle_redirects_target_user_id_users_id_fk", + "tableFrom": "handle_redirects", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "handle_redirects_old_handle_lowercase": { + "name": "handle_redirects_old_handle_lowercase", + "value": "\"handle_redirects\".\"old_handle\" = lower(\"handle_redirects\".\"old_handle\")" + } + }, + "isRLSEnabled": false + }, + "public.pending_uploads": { + "name": "pending_uploads", + "schema": "", + "columns": { + "staging_key": { + "name": "staging_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_uploads_user_id_size_bytes_idx": { + "name": "pending_uploads_user_id_size_bytes_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "size_bytes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_uploads_user_id_expires_at_idx": { + "name": "pending_uploads_user_id_expires_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_uploads_user_id_users_id_fk": { + "name": "pending_uploads_user_id_users_id_fk", + "tableFrom": "pending_uploads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_uploads_size_positive": { + "name": "pending_uploads_size_positive", + "value": "\"pending_uploads\".\"size_bytes\" > 0" + }, + "pending_uploads_window_forward": { + "name": "pending_uploads_window_forward", + "value": "\"pending_uploads\".\"expires_at\" >= \"pending_uploads\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.places": { + "name": "places", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "place_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_folded": { + "name": "name_folded", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commune": { + "name": "commune", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county_kind": { + "name": "county_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voivodeship": { + "name": "voivodeship", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "as_of": { + "name": "as_of", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "places_name_folded_prefix_idx": { + "name": "places_name_folded_prefix_idx", + "columns": [ + { + "expression": "name_folded", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_pattern_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "places_kind_idx": { + "name": "places_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "places_name_not_blank": { + "name": "places_name_not_blank", + "value": "length(btrim(\"places\".\"name\")) > 0" + }, + "places_rank_range": { + "name": "places_rank_range", + "value": "\"places\".\"rank\" BETWEEN 0 AND 9" + }, + "places_county_kind_known": { + "name": "places_county_kind_known", + "value": "\"places\".\"county_kind\" IS NULL OR \"places\".\"county_kind\" IN ('county', 'cityCounty')" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle_changed_at": { + "name": "handle_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "avatar_file_id": { + "name": "avatar_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locations": { + "name": "locations", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_file_id": { + "name": "cover_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "profiles_handle_unique": { + "name": "profiles_handle_unique", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_avatar_file_id_idx": { + "name": "profiles_avatar_file_id_idx", + "columns": [ + { + "expression": "avatar_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profiles_cover_file_id_idx": { + "name": "profiles_cover_file_id_idx", + "columns": [ + { + "expression": "cover_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profiles_user_id_users_id_fk": { + "name": "profiles_user_id_users_id_fk", + "tableFrom": "profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "profiles_avatar_file_id_files_id_fk": { + "name": "profiles_avatar_file_id_files_id_fk", + "tableFrom": "profiles", + "tableTo": "files", + "columnsFrom": [ + "avatar_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "profiles_cover_file_id_files_id_fk": { + "name": "profiles_cover_file_id_files_id_fk", + "tableFrom": "profiles", + "tableTo": "files", + "columnsFrom": [ + "cover_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "profiles_handle_lowercase": { + "name": "profiles_handle_lowercase", + "value": "\"profiles\".\"handle\" = lower(\"profiles\".\"handle\")" + }, + "profiles_display_name_not_blank": { + "name": "profiles_display_name_not_blank", + "value": "length(btrim(\"profiles\".\"display_name\")) > 0" + }, + "profiles_headline_length": { + "name": "profiles_headline_length", + "value": "\"profiles\".\"headline\" IS NULL OR length(\"profiles\".\"headline\") <= 220" + }, + "profiles_bio_length": { + "name": "profiles_bio_length", + "value": "\"profiles\".\"bio\" IS NULL OR length(\"profiles\".\"bio\") <= 1500" + }, + "profiles_locations_count": { + "name": "profiles_locations_count", + "value": "cardinality(\"profiles\".\"locations\") <= 8" + }, + "profiles_locations_no_null": { + "name": "profiles_locations_no_null", + "value": "array_position(\"profiles\".\"locations\", NULL) IS NULL" + }, + "profiles_locations_total_length": { + "name": "profiles_locations_total_length", + "value": "length(array_to_string(\"profiles\".\"locations\", '')) <= 640" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factors": { + "name": "two_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "two_factors_user_id_unique": { + "name": "two_factors_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factors_user_id_users_id_fk": { + "name": "two_factors_user_id_users_id_fk", + "tableFrom": "two_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_lower_unique": { + "name": "users_email_lower_unique", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_images": { + "name": "work_images", + "schema": "", + "columns": { + "work_id": { + "name": "work_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secondary_file_id": { + "name": "secondary_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "work_images_secondary_file_id_idx": { + "name": "work_images_secondary_file_id_idx", + "columns": [ + { + "expression": "secondary_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_images_work_id_file_id_unique": { + "name": "work_images_work_id_file_id_unique", + "columns": [ + { + "expression": "work_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_images_file_id_idx": { + "name": "work_images_file_id_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_images_work_id_works_id_fk": { + "name": "work_images_work_id_works_id_fk", + "tableFrom": "work_images", + "tableTo": "works", + "columnsFrom": [ + "work_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_images_file_id_files_id_fk": { + "name": "work_images_file_id_files_id_fk", + "tableFrom": "work_images", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "work_images_secondary_file_id_files_id_fk": { + "name": "work_images_secondary_file_id_files_id_fk", + "tableFrom": "work_images", + "tableTo": "files", + "columnsFrom": [ + "secondary_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "work_images_work_id_position_pk": { + "name": "work_images_work_id_position_pk", + "columns": [ + "work_id", + "position" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "work_images_secondary_differs": { + "name": "work_images_secondary_differs", + "value": "\"work_images\".\"secondary_file_id\" IS NULL OR \"work_images\".\"secondary_file_id\" <> \"work_images\".\"file_id\"" + }, + "work_images_position_range": { + "name": "work_images_position_range", + "value": "\"work_images\".\"position\" >= 0 AND \"work_images\".\"position\" <= 2" + } + }, + "isRLSEnabled": false + }, + "public.works": { + "name": "works", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "investor": { + "name": "investor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developer": { + "name": "developer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r360_set_id": { + "name": "r360_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r360_params": { + "name": "r360_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "r360_key_prefix": { + "name": "r360_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "works_user_id_position_idx": { + "name": "works_user_id_position_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "works_r360_set_id_unique": { + "name": "works_r360_set_id_unique", + "columns": [ + { + "expression": "r360_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "works_user_id_users_id_fk": { + "name": "works_user_id_users_id_fk", + "tableFrom": "works", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "works_r360_set_pairing": { + "name": "works_r360_set_pairing", + "value": "(\"works\".\"r360_set_id\" IS NULL) = (\"works\".\"r360_params\" IS NULL)" + }, + "works_r360_set_id_format": { + "name": "works_r360_set_id_format", + "value": "\"works\".\"r360_set_id\" IS NULL OR \"works\".\"r360_set_id\" ~ '^[0-9a-f]{32}$'" + }, + "works_r360_key_prefix_names_the_set": { + "name": "works_r360_key_prefix_names_the_set", + "value": "\"works\".\"r360_key_prefix\" IS NULL OR (\"works\".\"r360_set_id\" IS NOT NULL AND right(\"works\".\"r360_key_prefix\", 39) = '/r360/' || \"works\".\"r360_set_id\" || '/')" + }, + "works_name_not_blank": { + "name": "works_name_not_blank", + "value": "length(btrim(\"works\".\"name\")) > 0" + }, + "works_name_length": { + "name": "works_name_length", + "value": "length(\"works\".\"name\") <= 120" + }, + "works_investor_length": { + "name": "works_investor_length", + "value": "\"works\".\"investor\" IS NULL OR length(\"works\".\"investor\") <= 120" + }, + "works_developer_length": { + "name": "works_developer_length", + "value": "\"works\".\"developer\" IS NULL OR length(\"works\".\"developer\") <= 120" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.file_kind": { + "name": "file_kind", + "schema": "public", + "values": [ + "avatar-original", + "avatar-512", + "avatar-128", + "cover-original", + "cover-1600", + "cover-480", + "work-original", + "work-1600", + "work-480", + "r360-zip", + "r360-1600", + "r360-800" + ] + }, + "public.place_kind": { + "name": "place_kind", + "schema": "public", + "values": [ + "voivodeship", + "county", + "commune", + "city", + "village", + "settlement", + "part" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ad6663b..d599a38 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1789077950571, "tag": "0018_r360_key_prefix", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1789186112096, + "tag": "0019_works_position", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/e2e/db/profile-sections.spec.ts b/e2e/db/profile-sections.spec.ts index e5127ec..a537829 100644 --- a/e2e/db/profile-sections.spec.ts +++ b/e2e/db/profile-sections.spec.ts @@ -184,3 +184,51 @@ test("a signed-out visitor reads the sections, with the bio's paragraphs kept an await visitor.close(); } }); + +test("the places are put in order and stay in it, for the owner and for a visitor (#66)", async ({ + browser, +}) => { + await page.goto(`/${identity.handle}`); + await page.getByRole("button", { name: "Edytuj profil" }).click(); + + const chips = page.locator("li:has(button[aria-label^='Przesuń miejsce'])"); + expect(await chips.allInnerTexts()).toEqual(["Warszawa", "Nowa Wieś"]); + + // The arrow keys on the grip, which is the whole of this for anyone not + // using a mouse — and, unlike a drag, it says what it did out loud. + const saved = page.waitForResponse((response) => + response.url().includes("/api/profile/sections"), + ); + await page.getByRole("button", { name: "Przesuń miejsce Nowa Wieś" }).focus(); + await page.keyboard.press("ArrowLeft"); + expect((await saved).status()).toBe(200); + await expect( + page.getByText("Miejsce Nowa Wieś jest teraz na pozycji 1"), + ).toHaveCount(1); + expect(await chips.allInnerTexts()).toEqual(["Nowa Wieś", "Warszawa"]); + + await page.reload(); + const section = page.locator("section", { + has: page.getByRole("heading", { name: "Siedziba i obszar działania" }), + }); + expect(await section.locator("li").allInnerTexts()).toEqual([ + "Nowa Wieś", + "Warszawa", + ]); + + const visitor = await browser.newPage({ locale: "pl-PL" }); + try { + await visitor.goto(`/${identity.handle}`); + const theirs = visitor.locator("section", { + has: visitor.getByRole("heading", { + name: "Siedziba i obszar działania", + }), + }); + expect(await theirs.locator("li").allInnerTexts()).toEqual([ + "Nowa Wieś", + "Warszawa", + ]); + } finally { + await visitor.close(); + } +}); diff --git a/e2e/db/seed-works.ts b/e2e/db/seed-works.ts index b2bb081..f213582 100644 --- a/e2e/db/seed-works.ts +++ b/e2e/db/seed-works.ts @@ -74,12 +74,18 @@ export async function seedWorks( values ($1, $2, 1, 'work-original', 'png') returning id`, [userId, randomBytes(32).toString("hex")], ); + // #66: the position is written here rather than left to the default, + // because the order these are seeded in is the order the tests expect to + // read back — and with every row sharing a default, the tie would be + // broken by a random uuid. + let position = 0; const photoWork = async (name: string, secondChannel: boolean) => { const fileId = await placeholder(); const secondaryId = secondChannel ? await placeholder() : null; const workId = await insertId( - `insert into works (user_id, name) values ($1, $2) returning id`, - [userId, name], + `insert into works (user_id, name, position) + values ($1, $2, $3) returning id`, + [userId, name, position++], ); await client.query( `insert into work_images (work_id, file_id, secondary_file_id, position) @@ -95,9 +101,9 @@ export async function seedWorks( ...(r360.cues ? { cues: r360.cues } : {}), }; return insertId( - `insert into works (user_id, name, r360_set_id, r360_params) - values ($1, $2, $3, $4) returning id`, - [userId, name, randomBytes(16).toString("hex"), params], + `insert into works (user_id, name, r360_set_id, r360_params, position) + values ($1, $2, $3, $4, $5) returning id`, + [userId, name, randomBytes(16).toString("hex"), params, position++], ); }; const seeded: SeededWork[] = []; diff --git a/e2e/db/works-order.spec.ts b/e2e/db/works-order.spec.ts new file mode 100644 index 0000000..13bc524 --- /dev/null +++ b/e2e/db/works-order.spec.ts @@ -0,0 +1,141 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + completeOnboarding, + logIn, + newIdentity, + registerAndVerify, + type Identity, +} from "./account"; +import { seedWorks } from "./seed-works"; + +// #66: the owner drags a work to the front of their profile and it stays +// there — for them and for a visitor. Both hands are covered, because they +// are different code paths: the pointer (which is what a finger does too) +// and the arrow keys on the same grip, which is the whole of the feature for +// anyone not using a mouse. + +if (process.env.CI && !process.env.DATABASE_URL_TEST?.trim()) { + throw new Error( + "DATABASE_URL_TEST is unset, so the chromium-db project would skip itself. In CI that is a broken workflow: check the step env in .github/workflows/ci.yml.", + ); +} +test.skip( + !process.env.DATABASE_URL_TEST?.trim(), + "no DATABASE_URL_TEST: the owner's page is behind a real session", +); + +test.use({ locale: "pl-PL" }); +test.setTimeout(180_000); +test.describe.configure({ mode: "serial" }); + +const FIRST = "Osiedle nad rzeką"; +const SECOND = "Biurowiec przy rondzie"; +const THIRD = "Dom w lesie"; + +let page: Page; +let identity: Identity; + +/** The work names as the page lists them, top to bottom. */ +async function namesOn(target: Page): Promise { + return target.locator("article h3").allInnerTexts(); +} + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage({ locale: "pl-PL" }); + identity = newIdentity(); + await registerAndVerify(page, identity); + await logIn(page, identity); + await completeOnboarding(page, identity); + await seedWorks(identity.handle, [FIRST, SECOND, THIRD]); +}); + +test.afterAll(async () => { + await page.close(); +}); + +test("the grips appear only while editing", async () => { + await page.goto(`/${identity.handle}`); + expect(await namesOn(page)).toEqual([FIRST, SECOND, THIRD]); + await expect( + page.getByRole("button", { name: /^Przesuń realizację/ }), + ).toHaveCount(0); + + await page.getByRole("button", { name: "Edytuj profil" }).click(); + await expect( + page.getByRole("button", { name: /^Przesuń realizację/ }), + ).toHaveCount(3); +}); + +test("the arrow keys on a grip move a work, and the order survives a reload", async () => { + const last = page.getByRole("button", { + name: `Przesuń realizację ${THIRD}`, + }); + await last.focus(); + // The order is saved as it is dragged, so the request is part of the + // behaviour: a green screen and a silent server is the failure this test + // exists to catch. + const saved = page.waitForResponse((response) => + response.url().includes("/api/works/order"), + ); + await page.keyboard.press("ArrowUp"); + expect((await saved).status()).toBe(200); + // Said out loud, for a screen reader: the cards moving is the only other + // sign that anything happened. + await expect( + page.getByText(`Realizacja ${THIRD} jest teraz na pozycji 2`), + ).toHaveCount(1); + expect(await namesOn(page)).toEqual([FIRST, THIRD, SECOND]); + // The keyboard stays on the work it moved, or a second press would move + // whatever took its place. + await page.keyboard.press("ArrowUp"); + expect(await namesOn(page)).toEqual([THIRD, FIRST, SECOND]); + + await page.reload(); + expect(await namesOn(page)).toEqual([THIRD, FIRST, SECOND]); +}); + +test("a work dragged by its grip lands where it was dropped", async () => { + await page.getByRole("button", { name: "Edytuj profil" }).click(); + const grip = page.getByRole("button", { + name: `Przesuń realizację ${SECOND}`, + }); + // Both ends of the drag have to be on screen at the same time: the mouse + // is moved in viewport coordinates, and a card below the fold would be + // dragged at a point the page never sees. + await grip.scrollIntoViewIfNeeded(); + const first = page.locator("article").first(); + const from = await grip.boundingBox(); + const onto = await first.boundingBox(); + if (!from || !onto) throw new Error("no layout to drag over"); + + const saved = page.waitForResponse((response) => + response.url().includes("/api/works/order"), + ); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + // In steps, as a hand does: the frames in between are where a hand-rolled + // drag tends to lose its grip. + await page.mouse.move(onto.x + onto.width / 2, onto.y + onto.height / 2, { + steps: 8, + }); + await page.mouse.up(); + expect((await saved).status()).toBe(200); + + expect(await namesOn(page)).toEqual([SECOND, THIRD, FIRST]); + await page.reload(); + expect(await namesOn(page)).toEqual([SECOND, THIRD, FIRST]); +}); + +test("a visitor sees the owner's order", async ({ browser }) => { + const visitor = await browser.newPage({ locale: "pl-PL" }); + try { + await visitor.goto(`/${identity.handle}`); + expect(await namesOn(visitor)).toEqual([SECOND, THIRD, FIRST]); + // Nothing to take hold of on someone else's profile. + await expect( + visitor.getByRole("button", { name: /^Przesuń realizację/ }), + ).toHaveCount(0); + } finally { + await visitor.close(); + } +}); diff --git a/messages/en.json b/messages/en.json index 440005c..fa066e4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -231,8 +231,12 @@ "locations": { "label": "Add a place", "placeholder": "City, voivodeship or your own words", - "hint": "Suggestions come from the TERYT register. Enter adds your own text, e.g. “all of Poland” or “Berlin”.", + "hint": "Suggestions come from the TERYT register. Enter — or Tab on a phone keyboard — adds your own text, e.g. “all of Poland” or “Berlin”.", "remove": "Remove {place}", + "added": "Place added: {place}", + "grip": "Move {place}, {position} of {count}", + "moved": "{place} is now number {position}", + "removed": "Place removed: {place}", "suggestions": "Suggestions", "kind": { "voivodeship": "voivodeship", @@ -467,6 +471,12 @@ "developer": "Developer", "r360Ready": "R360 ready", "r360None": "No R360", + "move": "Move {name}, {position} of {count}", + "moveHint": "Use the arrow keys to move it forward and back.", + "moveStale": "The list of works changed elsewhere. Reloading it — set the order again.", + "moved": "{name} is now number {position}", + "moveFailed": "The order could not be saved. Try again.", + "moveRateLimited": "Too many changes at once. Wait a moment.", "orbit": "360° view", "edit": "Edit", "delete": "Delete", diff --git a/messages/pl.json b/messages/pl.json index a2b358a..97cdfe4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -231,8 +231,12 @@ "locations": { "label": "Dodaj miejsce", "placeholder": "Miasto, województwo albo własny opis", - "hint": "Podpowiedzi z rejestru TERYT: województwa, powiaty, gminy i wszystkie miejscowości. Enter dodaje własny tekst, np. „cała Polska” albo „Berlin”.", + "hint": "Podpowiedzi z rejestru TERYT: województwa, powiaty, gminy i wszystkie miejscowości. Enter — albo Tab na klawiaturze telefonu — dodaje własny tekst, np. „cała Polska” albo „Berlin”.", "remove": "Usuń {place}", + "added": "Dodano miejsce: {place}", + "grip": "Przesuń miejsce {place}, {position} z {count}", + "moved": "Miejsce {place} jest teraz na pozycji {position}", + "removed": "Usunięto miejsce: {place}", "suggestions": "Podpowiedzi", "kind": { "voivodeship": "województwo", @@ -467,6 +471,12 @@ "developer": "Deweloper", "r360Ready": "R360 gotowy", "r360None": "Bez R360", + "move": "Przesuń realizację {name}, {position} z {count}", + "moveHint": "Strzałkami przesuniesz w przód i w tył listy.", + "moveStale": "Lista realizacji zmieniła się gdzie indziej. Odświeżamy ją — ustaw kolejność jeszcze raz.", + "moved": "Realizacja {name} jest teraz na pozycji {position}", + "moveFailed": "Nie udało się zapisać kolejności. Spróbuj jeszcze raz.", + "moveRateLimited": "Za dużo zmian kolejności naraz. Odczekaj chwilę.", "orbit": "Widok 360°", "edit": "Edytuj", "delete": "Usuń", diff --git a/src/app/[locale]/(auth)/email-changed/page.tsx b/src/app/[locale]/(auth)/email-changed/page.tsx index a40bcd7..a54a908 100644 --- a/src/app/[locale]/(auth)/email-changed/page.tsx +++ b/src/app/[locale]/(auth)/email-changed/page.tsx @@ -45,7 +45,7 @@ export default async function EmailChangedPage({ return (
- +

{t(`${state}Heading`)}

diff --git a/src/app/[locale]/(auth)/login/page.tsx b/src/app/[locale]/(auth)/login/page.tsx index 7feaebb..5320377 100644 --- a/src/app/[locale]/(auth)/login/page.tsx +++ b/src/app/[locale]/(auth)/login/page.tsx @@ -28,7 +28,7 @@ export default async function LoginPage({ return (

- +

{t("heading")}

diff --git a/src/app/[locale]/(auth)/register/page.tsx b/src/app/[locale]/(auth)/register/page.tsx index 98987d8..9e3494e 100644 --- a/src/app/[locale]/(auth)/register/page.tsx +++ b/src/app/[locale]/(auth)/register/page.tsx @@ -28,7 +28,7 @@ export default async function RegisterPage({ return (
- +

{t("heading")}

diff --git a/src/app/[locale]/(auth)/register/verified/page.tsx b/src/app/[locale]/(auth)/register/verified/page.tsx index 9401bd2..06f32b8 100644 --- a/src/app/[locale]/(auth)/register/verified/page.tsx +++ b/src/app/[locale]/(auth)/register/verified/page.tsx @@ -45,7 +45,7 @@ export default async function VerifiedPage({ return (
- + {state === "success" ? ( <> diff --git a/src/app/[locale]/(auth)/reset-password/new/page.tsx b/src/app/[locale]/(auth)/reset-password/new/page.tsx index 0e4786d..876f414 100644 --- a/src/app/[locale]/(auth)/reset-password/new/page.tsx +++ b/src/app/[locale]/(auth)/reset-password/new/page.tsx @@ -35,7 +35,7 @@ export default async function NewPasswordPage({ return (
- + {token ? ( <> diff --git a/src/app/[locale]/(auth)/reset-password/page.tsx b/src/app/[locale]/(auth)/reset-password/page.tsx index 7862ed2..88ddc92 100644 --- a/src/app/[locale]/(auth)/reset-password/page.tsx +++ b/src/app/[locale]/(auth)/reset-password/page.tsx @@ -28,7 +28,7 @@ export default async function ResetPasswordPage({ return (
- +

{t("heading")}

diff --git a/src/app/[locale]/(auth)/two-factor/page.tsx b/src/app/[locale]/(auth)/two-factor/page.tsx index a0d079e..6561bdd 100644 --- a/src/app/[locale]/(auth)/two-factor/page.tsx +++ b/src/app/[locale]/(auth)/two-factor/page.tsx @@ -34,7 +34,7 @@ export default async function TwoFactorPage({ return (
- +

{t("heading")}

diff --git a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx index 7e71d57..807cd92 100644 --- a/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx +++ b/src/app/[locale]/(public)/[handle]/owner-profile-view.tsx @@ -15,7 +15,9 @@ import { Plaque } from "@/components/ui/plaque"; import { Textarea } from "@/components/ui/textarea"; import { TopBar } from "@/components/ui/top-bar"; import { UploadProgress } from "@/components/ui/upload-progress"; +import { useReorder } from "@/components/ui/use-reorder"; import { postJson } from "@/lib/api-client"; +import { moveItem } from "@/lib/reorder"; import { IMAGE_CONTENT_TYPES } from "@/lib/image-upload-shared"; import { BIO_MAX, @@ -72,6 +74,9 @@ function fieldsOf(profile: OwnerProfile): Fields { }; } +/** #66: how long the order waits for the moving to stop before it is sent. */ +const ORDER_SAVE_AFTER_MS = 300; + type SectionsErrorKey = "invalid" | "rateLimited" | "generic"; // The one call every section makes: a subset of the A12 fields, answered @@ -157,6 +162,36 @@ export function OwnerProfileView({ const [headlineError, setHeadlineError] = useState(null); const [bioError, setBioError] = useState(null); const [locationsError, setLocationsError] = useState(null); + /** What a screen reader is told about the place list, out loud (#66). */ + const [locationsNotice, setLocationsNotice] = useState(""); + /** #66: the places are dragged into order, and answer the arrow keys. */ + const placeOrder = useReorder({ + count: fields.locations.length, + onMove: (from, to) => movePlace(from, to), + }); + // #66: the works in the order on screen. The prop is the truth until a drag + // moves one — the order shows at once and a refused save puts it back, the + // same bargain the fields on this page make. + const [orderedWorks, setOrderedWorks] = useState(works); + const [syncedWorks, setSyncedWorks] = useState(works); + // An order the server has not acknowledged yet. While one stands, a + // refresh from somewhere else on this page (an avatar landing, a work + // saved) must not re-seed the list under it and throw the move away. + const [orderDirty, setOrderDirty] = useState(false); + if (works !== syncedWorks && !orderDirty) { + setSyncedWorks(works); + setOrderedWorks(works); + } + const [worksNotice, setWorksNotice] = useState(""); + const [worksOrderError, setWorksOrderError] = useState(null); + const pendingOrder = useRef(null); + const orderTimer = useRef | undefined>( + undefined, + ); + const workOrder = useReorder({ + count: orderedWorks.length, + onMove: (from, to) => moveWork(from, to), + }); const [avatarBusy, setAvatarBusy] = useState(false); // #80: the bytes on their way, 0..1, then 1 while the server processes; @@ -176,6 +211,8 @@ export function OwnerProfileView({ const headlineId = useId(); const bioId = useId(); const locationsId = useId(); + /** #66: the one line telling a keyboard which keys move a thing. */ + const moveHintId = useId(); // Saves still in flight, each resolving to whether it succeeded, so // "Zapisz" can wait for them and stay open if one failed. @@ -291,15 +328,104 @@ export function OwnerProfileView({ setLocationsError(tSections("locations.tooMany", { max: LOCATIONS_MAX })); return; } + // #66: a chip appearing is the whole of the feedback, and a chip is not + // announced. The field empties itself the moment a place is taken, which + // to a screen reader is indistinguishable from the text being thrown away. + setLocationsNotice(tSections("locations.added", { place })); void saveLocations([...fields.locations, place]); } function removePlace(place: string) { + setLocationsNotice(tSections("locations.removed", { place })); void saveLocations( fields.locations.filter((existing) => existing !== place), ); } + // #66: a work moved. The whole order goes to the server, which refuses one + // about a list that is not the owner's current works — so a second tab + // adding or deleting a work makes this fail rather than shuffle something + // nobody touched. + // + // Sent once the moving stops, not once per move: an arrow key repeats + // about twenty-five times a second, and each press would otherwise be its + // own request — several in flight at once, committed in whatever order + // they reach the lock, with the rate limit refusing the tail of them. + function moveWork(from: number, to: number) { + const work = orderedWorks[from]; + if (work === undefined) return; + const next = moveItem(orderedWorks, from, to); + setOrderedWorks(next); + setWorksOrderError(null); + setWorksNotice(tWorks("card.moved", { name: work.name, position: to + 1 })); + queueOrderSave(next); + } + + function queueOrderSave(next: GalleryWork[]) { + pendingOrder.current = next; + setOrderDirty(true); + if (orderTimer.current !== undefined) clearTimeout(orderTimer.current); + orderTimer.current = setTimeout(() => { + void saveWorkOrder(); + }, ORDER_SAVE_AFTER_MS); + } + + /** + * Sends the newest order, if one is waiting. Resolves to whether the list + * on screen is what the server holds, so "Zapisz" can wait for it. + * + * Nothing is reverted from a snapshot on failure: by the time a refused + * save comes back, the list may have changed for a reason — a work deleted + * in another tab is exactly why `stale_order` exists — and putting an old + * copy back would resurrect it on screen, with its buttons, until a full + * page load. The server's own answer is the way back. + */ + function saveWorkOrder(): Promise { + if (orderTimer.current !== undefined) { + clearTimeout(orderTimer.current); + orderTimer.current = undefined; + } + const next = pendingOrder.current; + if (next === null) return Promise.resolve(true); + pendingOrder.current = null; + return track( + (async () => { + try { + const response = await postJson("/api/works/order", { + workIds: next.map((one) => one.id), + }); + if (response.ok) { + setOrderDirty(false); + return true; + } + setWorksOrderError( + tWorks( + response.status === 429 + ? "card.moveRateLimited" + : "card.moveStale", + ), + ); + } catch { + setWorksOrderError(tWorks("card.moveFailed")); + } + setOrderDirty(false); + router.refresh(); + return false; + })(), + ); + } + + // #66: the order of the places IS the array, so putting them in order is + // the same save as adding one — no column, no endpoint of its own. + function movePlace(from: number, to: number) { + const place = fields.locations[from]; + if (place === undefined) return; + setLocationsNotice( + tSections("locations.moved", { place, position: to + 1 }), + ); + void saveLocations(moveItem(fields.locations, from, to)); + } + // The words for a failed upload are shared by the avatar and the cover // (Settings.profile.upload); only the final "point the profile at it" // step has a code of its own per slot. @@ -425,6 +551,9 @@ export function OwnerProfileView({ if (outcome === "kept") return; if (outcome === "closed") setWorkForm(null); } + // A waiting order goes now rather than on its timer, or "Zapisano" + // would be claimed over a save that has not left the page (#66). + await saveWorkOrder(); const outcomes = await Promise.all([...pending.current]); if (outcomes.some((ok) => !ok)) return; setEditing(false); @@ -444,6 +573,7 @@ export function OwnerProfileView({ setHeadlineError(null); setBioError(null); setLocationsError(null); + setWorksOrderError(null); } return ( @@ -707,12 +837,25 @@ export function OwnerProfileView({ {tSections("locations.empty")} )} - {fields.locations.map((place) => ( -
  • + {fields.locations.map((place, index) => ( +
  • removePlace(place)} removeLabel={tSections("locations.remove", { place })} + grip={placeOrder.handleProps(index)} + gripLabel={tSections("locations.grip", { + place, + position: index + 1, + count: fields.locations.length, + })} + gripHint={moveHintId} + held={placeOrder.dragging === index} + landing={placeOrder.over === index} />
  • ))} @@ -724,6 +867,9 @@ export function OwnerProfileView({ onAdd={addPlace} error={locationsError} /> +

    + {locationsNotice} +

    ) : ( @@ -800,8 +946,22 @@ export function OwnerProfileView({ )} {works.length > 0 ? ( + tWorks("card.move", { + name: work.name, + position: at + 1, + count: of, + }), + describedBy: moveHintId, + } + : undefined + } // #86: the edited work's form stands where its card was. inPlace={ editing && workForm?.kind === "edit" @@ -844,6 +1004,20 @@ export function OwnerProfileView({ /> ) )} + {editing && worksOrderError && ( +

    + {worksOrderError} +

    + )} +

    + {worksNotice} +

    + {/* Named by every grip on the page (#66). A grip is a button, and + a button's keys are Enter and Space — neither of which moves + anything here, so the arrows have to be said out loud. */} +

    + {tWorks("card.moveHint")} +

    @@ -1041,6 +1215,10 @@ function PlaceCombobox({ placeholder={tSections("locations.placeholder")} autoComplete="off" maxLength={LOCATION_MAX} + // The field is not inside a form, so a phone has nothing to infer + // the key's job from and shows a bare return. "done" makes it say + // so — the hint under the field names both keys (#66). + enterKeyHint="done" role="combobox" aria-expanded={hits.length > 0} aria-controls={listId} @@ -1073,6 +1251,15 @@ function PlaceCombobox({ event.preventDefault(); const chosen = activeIndex >= 0 ? hits[activeIndex] : undefined; choose(chosen ? chosen.name : query); + } else if (event.key === "Tab" && query.trim()) { + // #66: a phone's keyboard offers "next" where a desktop offers + // Enter, and next is Tab — so a place typed on a phone could not + // be added at all: the key moved focus and the text went with + // it. Tab adds what Enter would and then goes on its way (no + // preventDefault), which on a desktop only rescues text that + // tabbing away was about to discard anyway. + const chosen = activeIndex >= 0 ? hits[activeIndex] : undefined; + choose(chosen ? chosen.name : query); } else if (event.key === "Escape") { setOpen(false); setActiveIndex(-1); diff --git a/src/app/[locale]/(public)/[handle]/profile-sections.tsx b/src/app/[locale]/(public)/[handle]/profile-sections.tsx index fdcfb70..12a5e8e 100644 --- a/src/app/[locale]/(public)/[handle]/profile-sections.tsx +++ b/src/app/[locale]/(public)/[handle]/profile-sections.tsx @@ -51,19 +51,56 @@ export function PlaceChip({ place, onRemove, removeLabel, + grip, + gripLabel, + gripHint, + held = false, + landing = false, }: { place: string; /** Present only while editing: the chip grows an × that removes it. */ onRemove?: () => void; removeLabel?: string; + /** + * Present only while editing (#66): the map pin becomes the grip that drags + * this place up the list, and answers the arrow keys. Whatever `useReorder` + * hands out for this index. + */ + grip?: React.ComponentPropsWithRef<"button">; + gripLabel?: string; + /** The element saying which keys move it, for a screen reader. */ + gripHint?: string; + /** This chip is the one being dragged. */ + held?: boolean; + /** This chip is where the dragged one would land. */ + landing?: boolean; }) { return ( - - + + {grip ? ( + + ) : ( + + )} {place} {onRemove && ( + )} + {/* Whether the work has an orbit at all, for the owner's own + glance down the list. */} + + {work.orbit ? t("card.r360Ready") : t("card.r360None")} + +
    {owner.editing && onEdit && onDelete && (
    {confirming ? ( diff --git a/src/app/api/works/order/route.ts b/src/app/api/works/order/route.ts new file mode 100644 index 0000000..e2d9dd2 --- /dev/null +++ b/src/app/api/works/order/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { + checkRateLimit, + parseJsonBody, + rejectCrossSite, + sessionUserId, +} from "@/lib/api-route"; +import { getDb } from "@/db/client"; +import { worksOrderSchema } from "@/lib/work-schemas"; +import { reorderWorks } from "@/lib/works"; +import { respondWithWorkResult } from "../respond"; + +// #66 / A12: the order the owner dragged their works into. The body names +// every work they have, first to last, and lib/works refuses an order about +// any other list — see reorderWorks. +// +// A dragged list saves on every drop, so the limit is looser than the one on +// adding a work: a minute of steady reordering is a plausible thing to do, +// a hundred of them in that minute is not. + +export async function POST(request: Request) { + const userId = await sessionUserId(); + if (!userId) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const crossSite = rejectCrossSite(request); + if (crossSite) return crossSite; + if ( + !checkRateLimit(`works-order:${userId}`, { windowSeconds: 60, max: 60 }) + ) { + return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + } + + const input = await parseJsonBody(request, worksOrderSchema); + if (!input) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + } + + return respondWithWorkResult(async () => { + await reorderWorks({ db: getDb(), userId }, input.workIds); + }); +} diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 0e02542..9202817 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -23,7 +23,8 @@ type IconName = | "upload" | "layers" | "chevron-up" - | "chevron-down"; + | "chevron-down" + | "grip-vertical"; const PATHS: Record = { "map-pin-off": ( @@ -132,6 +133,17 @@ const PATHS: Record = { ), + // #66: what a list takes hold of to be put in order. + "grip-vertical": ( + <> + + + + + + + + ), }; export function Icon({ diff --git a/src/components/ui/logo-mark.tsx b/src/components/ui/logo-mark.tsx index 135029e..4882e8c 100644 --- a/src/components/ui/logo-mark.tsx +++ b/src/components/ui/logo-mark.tsx @@ -3,7 +3,13 @@ import { Link } from "@/i18n/navigation"; import { Mark } from "./mark"; type LogoMarkProps = { - href: ComponentPropsWithoutRef["href"]; + /** + * Left out where the logo must not lead anywhere: above the auth cards, a + * visitor part-way through making an account is not offered a way out of + * the view (#66). No href, no anchor — rather than a link and a second + * prop that could contradict it. + */ + href?: ComponentPropsWithoutRef["href"]; wordmark: string; onPhoto?: boolean; /** "compact" is the smaller mark+type-h4 pairing above the auth cards @@ -21,19 +27,28 @@ export function LogoMark({ href, wordmark, onPhoto = false, size = "default" }: const text = onPhoto ? "text-(--text-on-photo)" : "text-(--text-strong)"; const markSize = size === "compact" ? 26 : 30; const wordmarkClass = size === "compact" ? "type-h4" : "type-h3"; + // whitespace-nowrap: "Architektów 3d" broke across two lines inside the top + // bar on a phone, which stretched the bar and shoved the actions off screen. + // It fits without wrapping at every width we support — 30px mark + 8px gap + + // ~123px of wordmark is 161px of the 328px a 360px screen leaves between the + // gutters — so the mark and type keep their one size rather than gaining a + // second, smaller pairing to maintain. + const row = "flex items-center gap-(--sp-3) whitespace-nowrap sm:gap-(--sp-4)"; + const mark = ( + <> + + {wordmark} + + ); + // Not a link, not focusable, and nothing to announce beyond the wordmark + // itself — a span, so a keyboard and a screen reader both pass it by. + if (href === undefined) return {mark}; return ( - // whitespace-nowrap: "Architektów 3d" broke across two lines inside the - // top bar on a phone, which stretched the bar and shoved the actions off - // screen. It fits without wrapping at every width we support — 30px mark - // + 8px gap + ~123px of wordmark is 161px of the 328px a 360px screen - // leaves between the gutters — so the mark and type keep their one size - // rather than gaining a second, smaller pairing to maintain. - - {wordmark} + {mark} ); } diff --git a/src/components/ui/logo.tsx b/src/components/ui/logo.tsx index f28ec8a..f478fbe 100644 --- a/src/components/ui/logo.tsx +++ b/src/components/ui/logo.tsx @@ -4,16 +4,18 @@ import { Link } from "@/i18n/navigation"; import { LogoMark } from "./logo-mark"; type LogoProps = { - href: ComponentPropsWithoutRef["href"]; + /** Omitted above the auth cards, where it must lead nowhere (#66). */ + href?: ComponentPropsWithoutRef["href"]; onPhoto?: boolean; size?: "default" | "compact"; }; -// Mark + wordmark, clickable everywhere: to the hero for a signed-out -// visitor, to the visitor's own public profile once signed in (the hero is -// unreachable once logged in) — callers pass the right href for the page -// they're on. A server component (like every page that renders it) so it -// can use getTranslations directly, matching this codebase's convention. +// Mark + wordmark: to the hero for a signed-out visitor, to the visitor's own +// public profile once signed in (the hero is unreachable once logged in) — +// callers pass the right href for the page they're on, or none at all where +// the logo is branding rather than a way out (#66). A server component (like +// every page that renders it) so it can use getTranslations directly, +// matching this codebase's convention. export async function Logo({ href, onPhoto = false, size = "default" }: LogoProps) { const t = await getTranslations("Brand"); const wordmark = t("wordmark"); diff --git a/src/components/ui/use-reorder.ts b/src/components/ui/use-reorder.ts new file mode 100644 index 0000000..7765c50 --- /dev/null +++ b/src/components/ui/use-reorder.ts @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { indexAtPoint, type Box } from "@/lib/reorder"; + +// #66: the hand that puts a list in order. Two lists use it and they look +// nothing alike — a wrapped row of place chips, a grid of work cards — so this +// knows only about a handle to take hold of and the boxes the items occupy. +// The arithmetic is lib/reorder.ts. +// +// Pointer events, not HTML5 drag-and-drop: `dragstart` never fires on a touch +// screen, and half of this product is used on a phone. One pointer path serves +// a mouse, a finger and a stylus, and the same handle answers the keyboard — +// arrows move the item one place, no grab mode to enter or forget to leave. + +export interface Reorder { + /** The item being dragged right now, or null. */ + dragging: number | null; + /** Where it would land if let go now, or null. */ + over: number | null; + /** Spread onto the element of each item: it is what gets measured. */ + itemProps: (index: number) => { + ref: (node: HTMLElement | null) => void; + }; + /** Spread onto the control inside each item that takes hold of it. */ + handleProps: (index: number) => { + ref: (node: HTMLElement | null) => void; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onPointerCancel: (event: React.PointerEvent) => void; + onLostPointerCapture: (event: React.PointerEvent) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + style: { touchAction: "none" }; + }; +} + +export function useReorder(options: { + count: number; + /** Called once a move is decided; the caller saves and announces it. */ + onMove: (from: number, to: number) => void; +}): Reorder { + const { count, onMove } = options; + // The drag in flight is kept in refs and mirrored into state. The refs are + // what the handlers read: a pointerup can be delivered before the render + // that a pointermove scheduled has committed, and a handler closed over the + // older render then sees the drag still sitting where it started — so a + // real drag, moved and dropped in one gesture, decided nothing at all. The + // state exists only so the cards can show which is held and where it lands. + const [dragging, setDragging] = useState(null); + const [over, setOver] = useState(null); + const draggingRef = useRef(null); + const overRef = useRef(null); + const items = useRef(new Map()); + const handles = useRef(new Map()); + // Measured once when the drag starts: nothing moves until it ends, and + // measuring per pointer move would read layout dozens of times a second. + const boxes = useRef([]); + // Held in a ref and refreshed after render, the way use-orbit.ts does: + // written during render, a ref is a value the component's output depends on + // without React being told, and the lint says so. + const onMoveRef = useRef(onMove); + useEffect(() => { + onMoveRef.current = onMove; + }, [onMove]); + // The handle to put focus back on after a keyboard move. The list re-renders + // in its new order, so the element that had focus is gone by then — without + // this, one arrow press moves the item and drops the keyboard out of the + // list entirely, which is the whole feature for anyone not using a mouse. + const refocus = useRef(null); + + // Deliberately a fresh closure per render: that is what makes React re-run + // it, which is how the focus restore below ever gets a chance to fire. + // Memoizing these — the obvious future tidy-up — silently ends keyboard + // reordering, and no test would notice. + const keep = useCallback( + (map: React.RefObject>, index: number) => + (node: HTMLElement | null) => { + if (node) map.current.set(index, node); + else map.current.delete(index); + if (map === handles && node && refocus.current === index) { + refocus.current = null; + node.focus(); + } + }, + [], + ); + + const itemProps = useCallback( + (index: number) => ({ ref: keep(items, index) }), + [keep], + ); + + const finish = useCallback(() => { + draggingRef.current = null; + overRef.current = null; + setDragging(null); + setOver(null); + boxes.current = []; + }, []); + + const handleProps = useCallback( + (index: number) => ({ + ref: keep(handles, index), + onPointerDown: (event: React.PointerEvent) => { + if (event.button !== 0 && event.pointerType === "mouse") return; + // One drag at a time. A second finger on another grip would take the + // drag over, and the first finger's release would then commit the + // second one's half-finished move — and make its own release a no-op. + if (draggingRef.current !== null) return; + // Every item or none: a box list with a hole in it would answer the + // wrong index for every item after the hole, and silently. + const measured: Box[] = []; + for (let at = 0; at < count; at++) { + const node = items.current.get(at); + if (!node) return; + measured.push(node.getBoundingClientRect()); + } + // Capture on the handle, so a drag that leaves the list — a finger + // over the page's edge, a mouse over the card next to it — keeps + // being reported here instead of ending where it left. + event.currentTarget.setPointerCapture(event.pointerId); + boxes.current = measured; + draggingRef.current = index; + overRef.current = index; + setDragging(index); + setOver(index); + }, + onPointerMove: (event: React.PointerEvent) => { + if (draggingRef.current === null) return; + const at = indexAtPoint(boxes.current, event.clientX, event.clientY); + // Pointer moves arrive dozens of times a second and every card here + // holds a live orbit: re-render only when the answer actually moves. + if (at === overRef.current) return; + overRef.current = at; + setOver(at); + }, + onPointerUp: (event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const from = draggingRef.current; + const to = overRef.current; + if (from !== null && to !== null && to !== from) { + onMoveRef.current(from, to); + } + finish(); + }, + // A cancelled pointer is not a decision: the browser took the gesture + // over (a scroll, a back-swipe), and the list stays as it was. + onPointerCancel: finish, + // Capture lost any other way — the grip unmounted because editing + // ended, or the list shrank under it. `pointercancel` does not fire + // then, and without this the card keeps its held look until the next + // drag. + onLostPointerCapture: finish, + onKeyDown: (event: React.KeyboardEvent) => { + const back = event.key === "ArrowLeft" || event.key === "ArrowUp"; + const on = event.key === "ArrowRight" || event.key === "ArrowDown"; + if (!back && !on) return; + const to = back ? index - 1 : index + 1; + if (to < 0 || to >= count) return; + event.preventDefault(); + refocus.current = to; + onMoveRef.current(index, to); + }, + style: { touchAction: "none" as const }, + }), + [count, finish, keep], + ); + + return { dragging, over, itemProps, handleProps }; +} diff --git a/src/db/schema.test.ts b/src/db/schema.test.ts index 17c0cdf..296d1bc 100644 --- a/src/db/schema.test.ts +++ b/src/db/schema.test.ts @@ -58,6 +58,8 @@ describe("schema tables (SPEC §9)", () => { "r360_params", // #140: where the set's frames are, recorded at save. "r360_key_prefix", + // #66: where the owner put this work among their own. + "position", "updated_at", "user_id", ].sort(), @@ -386,6 +388,9 @@ describe("generated migration SQL (G6 — migrations are the source of truth)", 'DROP CONSTRAINT "works_r360_file_id_files_id_fk"', 'DROP INDEX "works_r360_file_id_idx"', 'DROP INDEX "files_original_user_sha256_unique"', + // 0019 (#66): the works index re-created with the owner's own order in + // front of the adding order it used to carry alone; guarded above. + 'DROP INDEX "works_user_id_created_at_idx"', ...schema.fileKind.enumValues .slice(3) .map((value) => `ALTER TYPE "public"."file_kind" ADD VALUE '${value}'`), diff --git a/src/db/schema.ts b/src/db/schema.ts index 53ba8b0..faa48e5 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -260,8 +260,8 @@ export const profiles = pgTable( // #72: a work (realizacja) on a profile. At most 10 per profile — counted // by the application under the same per-user advisory lock the quota uses, -// because a CHECK cannot count rows. Order on the page is the order of -// adding (created_at); a position column arrives with reordering, if ever. +// because a CHECK cannot count rows. Order on the page was the order of +// adding until #66; it is now the owner's, and `position` carries it. // Cascade from users on purpose: a work is nothing but profile content, and // the files it points at are what actually blocks a user's deletion (their // rows restrict), which forces the object cleanup through code (G2). @@ -297,12 +297,23 @@ export const works = pgTable( // catching up. Null on a row the backfill could not place — the reader // falls back to the rebuilt prefix, which is what it did before. r360KeyPrefix: text("r360_key_prefix"), + // #66: where the owner put this work among their own. Not unique and not + // dense on purpose — the order is rewritten wholesale whenever it + // changes, so a gap or a repeat costs nothing but a tie, and created_at + // breaks that. Backfilled from created_at, so nothing shuffled the day + // the column arrived; a new work takes the next number after the last. + position: integer("position").notNull().default(0), createdAt: createdAt(), updatedAt: updatedAt(), }, (table) => [ - // The profile page lists a user's works in adding order. - index("works_user_id_created_at_idx").on(table.userId, table.createdAt), + // The profile page lists a user's works in the order the owner chose, + // adding order behind it (#66). + index("works_user_id_position_idx").on( + table.userId, + table.position, + table.createdAt, + ), // #102 review: a set belongs to one work — the second save of one set // is refused by the code and, should it slip past, by the database. uniqueIndex("works_r360_set_id_unique").on(table.r360SetId), diff --git a/src/lib/reorder.test.ts b/src/lib/reorder.test.ts new file mode 100644 index 0000000..82d2b5c --- /dev/null +++ b/src/lib/reorder.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { indexAtPoint, moveItem, type Box } from "./reorder"; + +// #66: the arithmetic under both reorderable lists — the places on a profile +// and the works below them. + +describe("moveItem", () => { + const list = ["a", "b", "c", "d"]; + + it("takes the last to the front, which is the thing this exists for", () => { + expect(moveItem(list, 3, 0)).toEqual(["d", "a", "b", "c"]); + }); + + it("closes the gap behind and makes room in front, both ways", () => { + expect(moveItem(list, 0, 2)).toEqual(["b", "c", "a", "d"]); + expect(moveItem(list, 2, 1)).toEqual(["a", "c", "b", "d"]); + expect(moveItem(list, 1, 3)).toEqual(["a", "c", "d", "b"]); + }); + + it("gives back the same order when there is nowhere to go", () => { + expect(moveItem(list, 2, 2)).toEqual(list); + expect(moveItem(list, -1, 0)).toEqual(list); + expect(moveItem(list, 0, 4)).toEqual(list); + expect(moveItem(list, 9, 9)).toEqual(list); + expect(moveItem([], 0, 0)).toEqual([]); + expect(moveItem(["only"], 0, 0)).toEqual(["only"]); + }); + + it("never hands back the list it was given", () => { + const next = moveItem(list, 1, 1); + expect(next).not.toBe(list); + next[0] = "changed"; + expect(list[0]).toBe("a"); + }); +}); + +describe("indexAtPoint", () => { + // Two chips side by side with a gap, and a third wrapped onto a second row. + const row: Box[] = [ + { left: 0, top: 0, right: 100, bottom: 40 }, + { left: 120, top: 0, right: 220, bottom: 40 }, + { left: 0, top: 60, right: 100, bottom: 100 }, + ]; + + it("answers the box the point is inside, edges included", () => { + expect(indexAtPoint(row, 50, 20)).toBe(0); + expect(indexAtPoint(row, 150, 20)).toBe(1); + expect(indexAtPoint(row, 50, 80)).toBe(2); + expect(indexAtPoint(row, 0, 0)).toBe(0); + expect(indexAtPoint(row, 220, 40)).toBe(1); + }); + + it("answers the nearest middle in the air between and around them", () => { + // In the gap, but closer to the first chip's middle. + expect(indexAtPoint(row, 105, 20)).toBe(0); + expect(indexAtPoint(row, 118, 20)).toBe(1); + // Below everything, and off to the right of everything. + expect(indexAtPoint(row, 50, 400)).toBe(2); + expect(indexAtPoint(row, 900, 20)).toBe(1); + }); + + it("has no answer only when there is nothing to be over", () => { + expect(indexAtPoint([], 10, 10)).toBeNull(); + }); + + it("gives a tie to the earlier item, so the answer does not depend on measuring order", () => { + const twins: Box[] = [ + { left: 0, top: 0, right: 100, bottom: 40 }, + { left: 200, top: 0, right: 300, bottom: 40 }, + ]; + // Exactly between the two middles: 50 and 250, so 150. + expect(indexAtPoint(twins, 150, 20)).toBe(0); + }); +}); diff --git a/src/lib/reorder.ts b/src/lib/reorder.ts new file mode 100644 index 0000000..5544d69 --- /dev/null +++ b/src/lib/reorder.ts @@ -0,0 +1,74 @@ +// #66: the arithmetic of putting a list in order by hand. Two lists want it — +// the places on a profile and the works below them — and they look nothing +// alike: one is a wrapped row of chips, the other a grid of cards. What they +// share is here, pure and tested; the hand on the pointer is +// components/ui/use-reorder.ts. + +/** A box on the page, as `getBoundingClientRect` gives it. */ +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * The list with the item at `from` moved to `to`, the rest closing the gap + * behind it and making room in front. Out-of-range indices and a move to + * where it already is give the list back unchanged — a caller that computed + * an index from a pointer should not have to check first. + */ +export function moveItem(list: readonly T[], from: number, to: number): T[] { + const last = list.length - 1; + if (from < 0 || from > last || to < 0 || to > last || from === to) { + return [...list]; + } + const next = [...list]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +} + +/** Whether the point is inside the box, edges counting as inside. */ +function contains(box: Box, x: number, y: number): boolean { + return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom; +} + +/** How far the point is from the box's middle, squared (the root is never + * needed: only the order of the distances matters). */ +function fromCentre(box: Box, x: number, y: number): number { + const dx = (box.left + box.right) / 2 - x; + const dy = (box.top + box.bottom) / 2 - y; + return dx * dx + dy * dy; +} + +/** + * Which item a pointer is over: the box it is inside, or failing that the box + * whose middle it is nearest. The fallback is what makes a drag usable — a + * finger in the gap between two chips, or below the last row of a grid, is + * still asking for somewhere in particular, and a drag that only answers while + * exactly over an item stalls wherever the layout has air in it. + * + * Null only when there is nothing to be over. Ties go to the earlier item, so + * the answer does not depend on the order boxes happen to be measured in. + */ +export function indexAtPoint( + boxes: readonly Box[], + x: number, + y: number, +): number | null { + if (boxes.length === 0) return null; + for (const [index, box] of boxes.entries()) { + if (contains(box, x, y)) return index; + } + let nearest = 0; + let best = fromCentre(boxes[0], x, y); + for (let index = 1; index < boxes.length; index++) { + const distance = fromCentre(boxes[index], x, y); + if (distance < best) { + best = distance; + nearest = index; + } + } + return nearest; +} diff --git a/src/lib/work-schemas.ts b/src/lib/work-schemas.ts index efec762..a3b297d 100644 --- a/src/lib/work-schemas.ts +++ b/src/lib/work-schemas.ts @@ -87,6 +87,25 @@ export const workInputSchema = z { message: "duplicate photo", path: ["secondaryFileIds"] }, ); +/** + * #66: the owner's order, as the ids of every work they have, first to last. + * Whole rather than a pair of indices: the client already knows the list it + * is looking at, and a request that names all of it can be checked against + * what the database holds instead of trusted to be about the same list. + */ +export const worksOrderSchema = z + .object({ + workIds: z + .array(z.uuid()) + .min(1) + .max(WORKS_MAX) + .refine((ids) => new Set(ids).size === ids.length, { + message: "duplicate work", + }), + }) + .strict(); +export type WorksOrderInput = z.input; + /** The second channels of a parsed work, one per photo (null = none). */ export function secondariesOf(work: { imageFileIds: string[]; diff --git a/src/lib/works.ts b/src/lib/works.ts index efa7acd..cc0f3d4 100644 --- a/src/lib/works.ts +++ b/src/lib/works.ts @@ -44,6 +44,8 @@ export class WorksError extends Error { | "invalid_archive" /** #102: a frame set kept while its archive changed. */ | "invalid_set" + /** #66: an order about a list of works that is not the one there now. */ + | "stale_order" | "not_found", ) { super(`work rejected: ${code}`); @@ -91,7 +93,12 @@ export interface WorkView { const WORK_VARIANTS = IMAGE_PROFILES.work.variants; -/** A user's works in adding order, with their photos' public URLs. */ +/** + * A user's works in the order the owner put them in, with their photos' + * public URLs. Adding order breaks a tie (#66): positions are rewritten + * wholesale, so they are neither unique nor dense, and two works that have + * never been reordered both sit at 0. + */ export async function listWorks(deps: ProfileReadDeps): Promise { const { db, storage, prefix, userId } = deps; const rows = await db @@ -106,7 +113,7 @@ export async function listWorks(deps: ProfileReadDeps): Promise { }) .from(works) .where(eq(works.userId, userId)) - .orderBy(asc(works.createdAt), asc(works.id)); + .orderBy(asc(works.position), asc(works.createdAt), asc(works.id)); if (rows.length === 0) return []; const workIds = rows.map((row) => row.id); @@ -331,8 +338,14 @@ export async function createWork( return withFrameSet(deps, parsed, (verified) => db.transaction(async (tx) => { await lockUser(tx, userId); - const [{ count }] = await tx - .select({ count: sql`count(*)::int` }) + // #66: the count and the last position in one pass, under the lock + // that already serialises adds — a new work goes after the ones there, + // wherever the owner has since dragged them. + const [{ count, lastPosition }] = await tx + .select({ + count: sql`count(*)::int`, + lastPosition: sql`coalesce(max(${works.position}), -1)::int`, + }) .from(works) .where(eq(works.userId, userId)); if (count >= WORKS_MAX) throw new WorksError("limit"); @@ -344,6 +357,7 @@ export async function createWork( userId, ...workColumnsOf(parsed), r360KeyPrefix: verified?.keyPrefix ?? null, + position: lastPosition + 1, }) .returning({ id: works.id }); await insertImageRows(tx, created.id, parsed); @@ -353,6 +367,51 @@ export async function createWork( ); } +/** + * #66: the owner's order for their own works, first to last. + * + * The request names every work the owner has, and this refuses anything else + * — an order written while another tab was adding or deleting one would + * otherwise be applied to a list it was never about, quietly moving works + * the owner never touched. The client refreshes and the owner drags again, + * which is the honest outcome of two tabs disagreeing. + * + * Positions are rewritten wholesale rather than shuffled, so they stay dense + * and gap-free; under the same per-user lock as every other works write. + */ +export async function reorderWorks( + deps: Pick, + workIds: string[], +): Promise { + const { db, userId } = deps; + await db.transaction(async (tx) => { + await lockUser(tx, userId); + const own = await tx + .select({ id: works.id }) + .from(works) + .where(eq(works.userId, userId)); + const mine = new Set(own.map((row) => row.id)); + const asked = new Set(workIds); + if (mine.size !== asked.size || workIds.some((id) => !mine.has(id))) { + throw new WorksError("stale_order"); + } + // One statement rather than ten: the lock and the pooled connection are + // held for a round trip instead of a dozen. The owner is still in the + // WHERE — the set comparison above is the first answer to "are these + // yours", this is the second, and neither is a comment about the other. + const pairs = sql.join( + workIds.map((id, position) => sql`(${id}::uuid, ${position}::int)`), + sql`, `, + ); + await tx.execute(sql` + update "works" as w + set "position" = v."position" + from (values ${pairs}) as v("id", "position") + where w."id" = v."id" and w."user_id" = ${userId}::uuid + `); + }); +} + /** * Replaces a work's fields, photos and orbit. The photos are a * delete-and-reinsert in one transaction: the (work_id, position) key is