Private builds Closes #1242

This commit is contained in:
Kalle
2023-04-23 11:55:00 +03:00
parent c83b967b8d
commit 1f6bda88b4
18 changed files with 152 additions and 25 deletions

View File

@@ -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 (
<div className="build">
<div className={clsx("build", { build__private: build.private })}>
<div>
<div className="build__top-row">
{modes && modes.length > 0 && (
@@ -114,18 +116,26 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
<div>•</div>
</>
) : null}
<time className={clsx({ invisible: !isMounted })}>
{isMounted
? databaseTimestampToDate(updatedAt).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
}
)
: "t"}
</time>
<div className="stack horizontal sm">
{build.private ? (
<div className="build__private-text">
<LockIcon className="build__private-icon" />{" "}
{t("common:build.private")}
</div>
) : null}
<time className={clsx({ invisible: !isMounted })}>
{isMounted
? databaseTimestampToDate(updatedAt).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
}
)
: "t"}
</time>
</div>
</div>
</div>
<div className="build__weapons">

View File

@@ -0,0 +1,18 @@
export function LockIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
}

View File

@@ -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"
)

View File

@@ -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"
),

View File

@@ -6,7 +6,8 @@ insert into
"modes",
"headGearSplId",
"clothesGearSplId",
"shoesGearSplId"
"shoesGearSplId",
"private"
)
values
(
@@ -16,5 +17,6 @@ values
@modes,
@headGearSplId,
@clothesGearSplId,
@shoesGearSplId
) returning *
@shoesGearSplId,
@private
) returning *

View File

@@ -40,6 +40,7 @@ interface CreateArgs {
shoesGearSplId: Build["shoesGearSplId"];
weaponSplIds: Array<BuildWeapon["weaponSplId"]>;
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<BuildsByUserRow>;
export function buildsByUserId({
userId,
loggedInUserId,
}: {
userId: Build["ownerId"];
loggedInUserId?: UserWithPlusTier["id"];
}) {
const rows = buildsByUserIdStm.all({
userId,
loggedInUserId,
}) as Array<BuildsByUserRow>;
return rows.map(augmentBuild);
}

View File

@@ -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()
)}`,

View File

@@ -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;

View File

@@ -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

View File

@@ -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"

View File

@@ -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 });

View File

@@ -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<MainWeaponId>,
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() {
<TitleInput />
<DescriptionTextarea />
<ModeCheckboxes />
<PrivateCheckbox />
<SubmitButton className="mt-4">{t("actions.submit")}</SubmitButton>
</Form>
</div>
@@ -282,6 +294,26 @@ function ModeCheckboxes() {
);
}
function PrivateCheckbox() {
const { buildToEdit } = useLoaderData<typeof loader>();
const { t } = useTranslation(["builds", "common"]);
return (
<div>
<Label htmlFor="private">{t("common:build.private")}</Label>
<input
id="private"
name="private"
type="checkbox"
defaultChecked={Boolean(buildToEdit?.private)}
/>
<FormMessage type="info" className="mt-0">
{t("builds:forms.private.info")}
</FormMessage>
</div>
);
}
function WeaponsSelector() {
const [searchParams] = useSearchParams();
const { buildToEdit } = useLoaderData<typeof loader>();

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
module.exports.up = function (db) {
db.prepare(
/* sql */ `alter table "Build" add "private" integer default 0`
).run();
};

View File

@@ -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}}'?",

View File

@@ -176,5 +176,7 @@
"divisions.WEST": "Tentatek",
"divisions.JPN": "Takoroka",
"xsearch.placements": "placements",
"xsearch.aliases": "Aliases:"
"xsearch.aliases": "Aliases:",
"build.private": "Private"
}