diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index 40464adc7..20f1139af 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -35,6 +35,7 @@ import { Image } from "./Image"; import { Popover } from "./Popover"; import { InfoIcon } from "./icons/Info"; import type { BuildWeaponWithTop500Info } from "~/db/models/builds/queries.server"; +import { LockIcon } from "./icons/Lock"; interface BuildProps { build: Pick< @@ -46,6 +47,7 @@ interface BuildProps { | "headGearSplId" | "shoesGearSplId" | "updatedAt" + | "private" > & { abilities: BuildAbilitiesTuple; modes: ModeShort[] | null; @@ -82,7 +84,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { } = build; return ( -
+
{modes && modes.length > 0 && ( @@ -114,18 +116,26 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
•
) : null} - +
+ {build.private ? ( +
+ {" "} + {t("common:build.private")} +
+ ) : null} + +
diff --git a/app/components/icons/Lock.tsx b/app/components/icons/Lock.tsx new file mode 100644 index 000000000..5f03d3fa6 --- /dev/null +++ b/app/components/icons/Lock.tsx @@ -0,0 +1,18 @@ +export function LockIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/db/models/builds/buildsByUserId.sql b/app/db/models/builds/buildsByUserId.sql index b3dc802c5..d0ca7a36c 100644 --- a/app/db/models/builds/buildsByUserId.sql +++ b/app/db/models/builds/buildsByUserId.sql @@ -22,6 +22,7 @@ with "Top500Weapon" as ( "clothesGearSplId", "shoesGearSplId", "updatedAt", + "private", json_group_array( json_object( 'weaponSplId', @@ -37,6 +38,10 @@ with "Top500Weapon" as ( left join "Top500Weapon" on "Top500Weapon"."buildId" = "Build"."id" where "Build"."ownerId" = @userId + and ( + "Build"."private" = 0 + or "Build"."ownerId" = @loggedInUserId + ) group by "Build"."id" ) diff --git a/app/db/models/builds/buildsByWeaponId.sql b/app/db/models/builds/buildsByWeaponId.sql index 81c3cf2bd..e8850207f 100644 --- a/app/db/models/builds/buildsByWeaponId.sql +++ b/app/db/models/builds/buildsByWeaponId.sql @@ -36,8 +36,11 @@ with "Top500Weapon" as ( "Build" left join "Top500Weapon" on "Top500Weapon"."buildId" = "Build"."id" where - "Top500Weapon"."weaponSplId" = @weaponId - or "Top500Weapon"."weaponSplId" = @altWeaponId + ( + "Top500Weapon"."weaponSplId" = @weaponId + or "Top500Weapon"."weaponSplId" = @altWeaponId + ) + and "Build"."private" = 0 group by "Build"."id" ), diff --git a/app/db/models/builds/createBuild.sql b/app/db/models/builds/createBuild.sql index 2d9ec99fd..b71db65f3 100644 --- a/app/db/models/builds/createBuild.sql +++ b/app/db/models/builds/createBuild.sql @@ -6,7 +6,8 @@ insert into "modes", "headGearSplId", "clothesGearSplId", - "shoesGearSplId" + "shoesGearSplId", + "private" ) values ( @@ -16,5 +17,6 @@ values @modes, @headGearSplId, @clothesGearSplId, - @shoesGearSplId - ) returning * \ No newline at end of file + @shoesGearSplId, + @private + ) returning * diff --git a/app/db/models/builds/queries.server.ts b/app/db/models/builds/queries.server.ts index 330fe958c..70dcec72c 100644 --- a/app/db/models/builds/queries.server.ts +++ b/app/db/models/builds/queries.server.ts @@ -40,6 +40,7 @@ interface CreateArgs { shoesGearSplId: Build["shoesGearSplId"]; weaponSplIds: Array; abilities: BuildAbilitiesTuple; + private: Build["private"]; } export const create = sql.transaction((build: CreateArgs) => { const createdBuild = createBuildStm.get({ @@ -57,6 +58,7 @@ export const create = sql.transaction((build: CreateArgs) => { headGearSplId: build.headGearSplId, clothesGearSplId: build.clothesGearSplId, shoesGearSplId: build.shoesGearSplId, + private: build.private, }) as Build; for (const weaponSplId of build.weaponSplIds) { @@ -108,12 +110,22 @@ type BuildsByUserRow = Pick< | "clothesGearSplId" | "shoesGearSplId" | "updatedAt" + | "private" > & { weapons: string; abilities: string; }; -export function buildsByUserId(userId: Build["ownerId"]) { - const rows = buildsByUserIdStm.all({ userId }) as Array; +export function buildsByUserId({ + userId, + loggedInUserId, +}: { + userId: Build["ownerId"]; + loggedInUserId?: UserWithPlusTier["id"]; +}) { + const rows = buildsByUserIdStm.all({ + userId, + loggedInUserId, + }) as Array; return rows.map(augmentBuild); } diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 77bff4f7c..c8ac0531d 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -834,6 +834,7 @@ function adminBuilds() { faker.word.noun() )}`, ownerId: 1, + private: 0, description: Math.random() < 0.75 ? faker.lorem.paragraph() : null, headGearSplId: randomOrderHeadGear[0]!, clothesGearSplId: randomOrderClothesGear[0]!, @@ -889,6 +890,7 @@ function manySplattershotBuilds() { ); db.builds.create({ + private: 0, title: `${capitalize(faker.word.adjective())} ${capitalize( faker.word.noun() )}`, diff --git a/app/db/types.ts b/app/db/types.ts index 2981a2ffa..90a491a94 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -151,6 +151,8 @@ export interface Build { id: number; ownerId: number; title: string; + /** Private builds are only visible on the user builds page to the owner only */ + private: number; description: string | null; modes: string | null; headGearSplId: number; diff --git a/app/features/build-stats/queries/abilitiesByWeaponId.server.ts b/app/features/build-stats/queries/abilitiesByWeaponId.server.ts index 118e61d51..4bd207bf3 100644 --- a/app/features/build-stats/queries/abilitiesByWeaponId.server.ts +++ b/app/features/build-stats/queries/abilitiesByWeaponId.server.ts @@ -1,6 +1,7 @@ import { sql } from "~/db/sql"; import type { Ability, MainWeaponId } from "~/modules/in-game-lists"; +// TODO: could consider removing private builds from this const stm = sql.prepare(/* sql */ ` with "GroupedAbilities" as ( select diff --git a/app/features/build-stats/queries/averageAbilityPoints.server.ts b/app/features/build-stats/queries/averageAbilityPoints.server.ts index e428a7462..a3acd7471 100644 --- a/app/features/build-stats/queries/averageAbilityPoints.server.ts +++ b/app/features/build-stats/queries/averageAbilityPoints.server.ts @@ -1,6 +1,7 @@ import { sql } from "~/db/sql"; import type { Ability, MainWeaponId } from "~/modules/in-game-lists"; +// TODO: could consider removing private builds from this const query = (includeWeaponId: boolean) => /* sql */ ` select "BuildAbility"."ability", sum("BuildAbility"."abilityPoints") as "abilityPointsSum" from "BuildAbility" diff --git a/app/routes/u.$identifier/builds/index.tsx b/app/routes/u.$identifier/builds/index.tsx index fcedd73d5..b9f9c07a7 100644 --- a/app/routes/u.$identifier/builds/index.tsx +++ b/app/routes/u.$identifier/builds/index.tsx @@ -32,7 +32,7 @@ export const action: ActionFunction = async ({ request }) => { if ( !db.builds - .buildsByUserId(user.id) + .buildsByUserId({ userId: user.id, loggedInUserId: user?.id }) .some((build) => build.id === data.buildToDeleteId) ) { throw new Response(null, { status: 400 }); @@ -52,7 +52,10 @@ export const loader = async ({ params, request }: LoaderArgs) => { const { identifier } = userParamsSchema.parse(params); const user = notFoundIfFalsy(db.users.findByIdentifier(identifier)); - const builds = db.builds.buildsByUserId(user.id); + const builds = db.builds.buildsByUserId({ + userId: user.id, + loggedInUserId: loggedInUser?.id, + }); if (builds.length === 0 && loggedInUser?.id !== user.id) { throw new Response(null, { status: 404 }); diff --git a/app/routes/u.$identifier/builds/new.tsx b/app/routes/u.$identifier/builds/new.tsx index 1ea8940e5..b52bddee5 100644 --- a/app/routes/u.$identifier/builds/new.tsx +++ b/app/routes/u.$identifier/builds/new.tsx @@ -10,6 +10,7 @@ import { z } from "zod"; import { AbilitiesSelector } from "~/components/AbilitiesSelector"; import { Button } from "~/components/Button"; import { GearCombobox, WeaponCombobox } from "~/components/Combobox"; +import { FormMessage } from "~/components/FormMessage"; import { CrossIcon } from "~/components/icons/Cross"; import { PlusIcon } from "~/components/icons/Plus"; import { Image } from "~/components/Image"; @@ -43,7 +44,9 @@ import { modeImageUrl, userBuildsPage } from "~/utils/urls"; import { actualNumber, checkboxValueToBoolean, + checkboxValueToDbBoolean, clothesMainSlotAbility, + dbBoolean, falsyToNull, headMainSlotAbility, id, @@ -68,6 +71,7 @@ const newBuildActionSchema = z.object({ TC: z.preprocess(checkboxValueToBoolean, z.boolean()), RM: z.preprocess(checkboxValueToBoolean, z.boolean()), CB: z.preprocess(checkboxValueToBoolean, z.boolean()), + private: z.preprocess(checkboxValueToDbBoolean, dbBoolean), "weapon[value]": z.preprocess( processMany(toArray, removeDuplicates), z.array(weaponSplId).min(1).max(BUILD.MAX_WEAPONS_COUNT) @@ -128,7 +132,10 @@ export const action: ActionFunction = async ({ request }) => { schema: newBuildActionSchema, }); - const usersBuilds = db.builds.buildsByUserId(user.id); + const usersBuilds = db.builds.buildsByUserId({ + userId: user.id, + loggedInUserId: user.id, + }); if (usersBuilds.length >= BUILD.MAX_COUNT) { throw new Response(null, { status: 400 }); } @@ -143,6 +150,7 @@ export const action: ActionFunction = async ({ request }) => { modes: modesShort.filter((mode) => data[mode]), weaponSplIds: data["weapon[value]"] as Array, ownerId: user.id, + private: data.private, }; if (data.buildToEditId) { db.builds.updateByReplacing({ id: data.buildToEditId, ...commonArgs }); @@ -174,7 +182,10 @@ export const loader = async ({ request }: LoaderArgs) => { return json({ buildToEdit: null }); } - const usersBuilds = db.builds.buildsByUserId(params.data.userId); + const usersBuilds = db.builds.buildsByUserId({ + userId: params.data.userId, + loggedInUserId: user.id, + }); const buildToEdit = usersBuilds.find((b) => b.id === params.data.buildId); return json({ @@ -200,6 +211,7 @@ export default function NewBuildPage() { + {t("actions.submit")}
@@ -282,6 +294,26 @@ function ModeCheckboxes() { ); } +function PrivateCheckbox() { + const { buildToEdit } = useLoaderData(); + const { t } = useTranslation(["builds", "common"]); + + return ( +
+ + + + {t("builds:forms.private.info")} + +
+ ); +} + function WeaponsSelector() { const [searchParams] = useSearchParams(); const { buildToEdit } = useLoaderData(); diff --git a/app/styles/common.css b/app/styles/common.css index 4bf2882a5..971dd8233 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -840,6 +840,23 @@ dialog::backdrop { gap: var(--s-3); } +.build__private { + background-color: var(--bg-lighter-transparent); +} + +.build__private-text { + display: flex; + justify-content: center; + font-weight: var(--semi-bold); + gap: var(--s-1); +} + +.build__private-icon { + width: 16px; + margin-block-end: var(--s-1); + stroke-width: 2px; +} + .build__title { overflow: hidden; height: 2.5rem; diff --git a/app/styles/utils.css b/app/styles/utils.css index 1ec0b7048..dde3a0bb8 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -122,6 +122,10 @@ padding-block-start: var(--s-12) !important; } +.mt-0 { + margin-block-start: var(--s-0); +} + .mt-2 { margin-block-start: var(--s-2); } diff --git a/app/utils/zod.ts b/app/utils/zod.ts index 919d4a8bc..a3cb18581 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -7,6 +7,7 @@ import { } from "~/modules/in-game-lists"; export const id = z.coerce.number().int().positive(); +export const dbBoolean = z.coerce.number().min(0).max(1).int(); const abilityNameToType = (val: string) => abilities.find((ability) => ability.name === val)?.type; @@ -145,3 +146,9 @@ export function checkboxValueToBoolean(value: unknown) { return value === "on"; } + +export function checkboxValueToDbBoolean(value: unknown) { + if (checkboxValueToBoolean(value)) return 1; + + return 0; +} diff --git a/migrations/025-private-builds.js b/migrations/025-private-builds.js new file mode 100644 index 000000000..55b8376a1 --- /dev/null +++ b/migrations/025-private-builds.js @@ -0,0 +1,5 @@ +module.exports.up = function (db) { + db.prepare( + /* sql */ `alter table "Build" add "private" integer default 0` + ).run(); +}; diff --git a/public/locales/en/builds.json b/public/locales/en/builds.json index 9cf64273a..c0a0692ec 100644 --- a/public/locales/en/builds.json +++ b/public/locales/en/builds.json @@ -11,6 +11,7 @@ "forms.gear.HEAD": "Gear (head)", "forms.gear.CLOTHES": "Gear (clothes)", "forms.gear.SHOES": "Gear (shoes)", + "forms.private.info": "Private builds are only visible to you", "deleteConfirm": "Delete build '{{title}}'?", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 7d7d2a59e..13abbb032 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -176,5 +176,7 @@ "divisions.WEST": "Tentatek", "divisions.JPN": "Takoroka", "xsearch.placements": "placements", - "xsearch.aliases": "Aliases:" + "xsearch.aliases": "Aliases:", + + "build.private": "Private" }