mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Private builds Closes #1242
This commit is contained in:
@@ -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">
|
||||
|
||||
18
app/components/icons/Lock.tsx
Normal file
18
app/components/icons/Lock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
migrations/025-private-builds.js
Normal file
5
migrations/025-private-builds.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports.up = function (db) {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "Build" add "private" integer default 0`
|
||||
).run();
|
||||
};
|
||||
@@ -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}}'?",
|
||||
|
||||
|
||||
@@ -176,5 +176,7 @@
|
||||
"divisions.WEST": "Tentatek",
|
||||
"divisions.JPN": "Takoroka",
|
||||
"xsearch.placements": "placements",
|
||||
"xsearch.aliases": "Aliases:"
|
||||
"xsearch.aliases": "Aliases:",
|
||||
|
||||
"build.private": "Private"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user