mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-31 08:06:51 -05:00
Group notes feature
This commit is contained in:
parent
f8f221708f
commit
bfb764f238
|
|
@ -13,7 +13,7 @@ export interface ButtonProps
|
|||
| "minimal"
|
||||
| "minimal-success"
|
||||
| "minimal-destructive";
|
||||
size?: "tiny" | "big";
|
||||
size?: "miniscule" | "tiny" | "big";
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
icon?: JSX.Element;
|
||||
|
|
@ -42,6 +42,7 @@ export function Button(props: ButtonProps) {
|
|||
loading,
|
||||
tiny: size === "tiny",
|
||||
big: size === "big",
|
||||
miniscule: size === "miniscule",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -535,6 +535,7 @@ export interface GroupMember {
|
|||
groupId: number;
|
||||
userId: number;
|
||||
role: "OWNER" | "MANAGER" | "REGULAR";
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Link, useFetcher } from "@remix-run/react";
|
|||
import clsx from "clsx";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, TierImage, WeaponImage } from "~/components/Image";
|
||||
import { Popover } from "~/components/Popover";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
|
|
@ -21,6 +20,7 @@ import type { LookingGroup } from "../q-types";
|
|||
import { StarIcon } from "~/components/icons/Star";
|
||||
import { StarFilledIcon } from "~/components/icons/StarFilled";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
import * as React from "react";
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
|
|
@ -31,6 +31,7 @@ export function GroupCard({
|
|||
displayOnly = false,
|
||||
hideVc = false,
|
||||
hideWeapons = false,
|
||||
hideNote: _hidenote = false,
|
||||
}: {
|
||||
group: LookingGroup;
|
||||
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP";
|
||||
|
|
@ -40,9 +41,15 @@ export function GroupCard({
|
|||
displayOnly?: boolean;
|
||||
hideVc?: boolean;
|
||||
hideWeapons?: boolean;
|
||||
hideNote?: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const leaveQFetcher = useFetcher();
|
||||
|
||||
const hideNote =
|
||||
displayOnly ||
|
||||
!group.members ||
|
||||
group.members.length === FULL_GROUP_SIZE ||
|
||||
_hidenote;
|
||||
|
||||
return (
|
||||
<section
|
||||
|
|
@ -62,6 +69,7 @@ export function GroupCard({
|
|||
displayOnly={displayOnly}
|
||||
hideVc={hideVc}
|
||||
hideWeapons={hideWeapons}
|
||||
hideNote={hideNote}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -122,33 +130,6 @@ export function GroupCard({
|
|||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
|
||||
{ownGroup && group.members!.length > 1 ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading="Leave this group?"
|
||||
fields={[["_action", "LEAVE_GROUP"]]}
|
||||
deleteButtonText="Leave"
|
||||
action={SENDOUQ_LOOKING_PAGE}
|
||||
>
|
||||
<Button variant="minimal-destructive" size="tiny">
|
||||
Leave group
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
{/* Leave without confirm if alone */}
|
||||
{ownGroup && group.members!.length === 1 ? (
|
||||
<leaveQFetcher.Form method="POST" action={SENDOUQ_LOOKING_PAGE}>
|
||||
<SubmitButton
|
||||
_action="LEAVE_GROUP"
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
state={fetcher.state}
|
||||
className="mx-auto"
|
||||
>
|
||||
Leave queue
|
||||
</SubmitButton>
|
||||
</leaveQFetcher.Form>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -159,13 +140,17 @@ function GroupMember({
|
|||
displayOnly,
|
||||
hideVc,
|
||||
hideWeapons,
|
||||
hideNote,
|
||||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[number];
|
||||
showActions: boolean;
|
||||
displayOnly?: boolean;
|
||||
hideVc?: boolean;
|
||||
hideWeapons?: boolean;
|
||||
hideNote?: boolean;
|
||||
}) {
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className="stack xxs">
|
||||
<div className="q__group-member">
|
||||
|
|
@ -225,10 +210,83 @@ function GroupMember({
|
|||
<MemberSkillDifference skillDifference={member.skillDifference} />
|
||||
) : null}
|
||||
</div>
|
||||
{!hideNote ? (
|
||||
<MemberNote note={member.note} editable={user?.id === member.id} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberNote({
|
||||
note,
|
||||
editable,
|
||||
}: {
|
||||
note?: string | null;
|
||||
editable: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [note]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<fetcher.Form method="post" action={SENDOUQ_LOOKING_PAGE}>
|
||||
<textarea
|
||||
defaultValue={note ?? ""}
|
||||
rows={2}
|
||||
className="q__group-member__note-textarea mt-1"
|
||||
name="value"
|
||||
/>
|
||||
<div className="stack horizontal justify-between">
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<SubmitButton
|
||||
_action="UPDATE_NOTE"
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
if (note) {
|
||||
return (
|
||||
<div className="text-lighter text-center text-xs mt-1">
|
||||
{note}{" "}
|
||||
{editable ? (
|
||||
<Button
|
||||
size="miniscule"
|
||||
variant="minimal"
|
||||
onClick={() => setEditing(true)}
|
||||
className="mt-2 ml-auto"
|
||||
>
|
||||
Edit note
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!editable) return null;
|
||||
|
||||
return (
|
||||
<Button variant="minimal" size="miniscule" onClick={() => setEditing(true)}>
|
||||
Add note
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSkillDifference({
|
||||
skillDifference,
|
||||
}: {
|
||||
|
|
|
|||
46
app/features/sendouq/components/GroupLeaver.tsx
Normal file
46
app/features/sendouq/components/GroupLeaver.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
|
||||
|
||||
export function GroupLeaver({
|
||||
type,
|
||||
}: {
|
||||
type: "LEAVE_GROUP" | "LEAVE_Q" | "GO_BACK";
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
if (type === "LEAVE_GROUP") {
|
||||
return (
|
||||
<FormWithConfirm
|
||||
dialogHeading="Leave this group?"
|
||||
fields={[["_action", "LEAVE_GROUP"]]}
|
||||
deleteButtonText="Leave"
|
||||
action={SENDOUQ_LOOKING_PAGE}
|
||||
>
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
className="self-start"
|
||||
>
|
||||
Leave group
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
|
||||
// leave without confirm if alone
|
||||
return (
|
||||
<fetcher.Form method="POST" action={SENDOUQ_LOOKING_PAGE}>
|
||||
<SubmitButton
|
||||
_action="LEAVE_GROUP"
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
state={fetcher.state}
|
||||
>
|
||||
{type === "LEAVE_Q" ? "Leave queue" : "Go back"}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { TWEET_LENGTH_MAX_LENGTH } from "~/constants";
|
||||
import type { Group } from "~/db/types";
|
||||
import { assertType } from "~/utils/types";
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ export const SENDOUQ = {
|
|||
SZ_MAP_COUNT: 6,
|
||||
OTHER_MODE_MAP_COUNT: 3,
|
||||
MAX_STAGE_REPEAT_COUNT: 2,
|
||||
NOTE_MAX_LENGTH: TWEET_LENGTH_MAX_LENGTH / 2,
|
||||
} as const;
|
||||
|
||||
export const FULL_GROUP_SIZE = 4;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||
import {
|
||||
FULL_GROUP_SIZE,
|
||||
MAP_LIST_PREFERENCE_OPTIONS,
|
||||
SENDOUQ,
|
||||
SENDOUQ_BEST_OF,
|
||||
} from "./q-constants";
|
||||
import {
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
weaponSplId,
|
||||
stageId,
|
||||
modeShort,
|
||||
falsyToNull,
|
||||
} from "~/utils/zod";
|
||||
import { matchEndedAtIndex } from "./core/match";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
|
|
@ -88,6 +90,13 @@ export const lookingSchema = z.union([
|
|||
z.object({
|
||||
_action: _action("REFRESH_GROUP"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_NOTE"),
|
||||
value: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(SENDOUQ.NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
const winners = z.preprocess(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export type LookingGroup = {
|
|||
customUrl?: User["customUrl"];
|
||||
plusTier?: PlusTier["tier"];
|
||||
role: GroupMember["role"];
|
||||
note?: GroupMember["note"];
|
||||
weapons?: MainWeaponId[];
|
||||
skill?: TieredSkill;
|
||||
vc?: User["vc"];
|
||||
|
|
|
|||
|
|
@ -175,6 +175,10 @@
|
|||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.q__group-member__note-textarea {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
.q__member-placeholder {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lightest);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
"User"."discordName",
|
||||
"User"."discordAvatar",
|
||||
"GroupMember"."role",
|
||||
"GroupMember"."note",
|
||||
json_group_array("UserWeapon"."weaponSplId") as "weapons"
|
||||
from
|
||||
"Group"
|
||||
|
|
@ -40,6 +41,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
'discordName', "q1"."discordName",
|
||||
'discordAvatar', "q1"."discordAvatar",
|
||||
'role', "q1"."role",
|
||||
'note', "q1"."note",
|
||||
'weapons', "q1"."weapons"
|
||||
)
|
||||
) as "members"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
"User"."languages",
|
||||
"PlusTier"."tier" as "plusTier",
|
||||
"GroupMember"."role",
|
||||
"GroupMember"."note",
|
||||
json_group_array("UserWeapon"."weaponSplId") as "weapons"
|
||||
from
|
||||
"Group"
|
||||
|
|
@ -55,6 +56,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
'customUrl', "q1"."customUrl",
|
||||
'plusTier', "q1"."plusTier",
|
||||
'role', "q1"."role",
|
||||
'note', "q1"."note",
|
||||
'weapons', "q1"."weapons",
|
||||
'vc', "q1"."vc",
|
||||
'languages', "q1"."languages"
|
||||
|
|
|
|||
16
app/features/sendouq/queries/updateNote.server.ts
Normal file
16
app/features/sendouq/queries/updateNote.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { GroupMember } from "~/db/types";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
update "GroupMember"
|
||||
set "note" = @note
|
||||
where "groupId" = @groupId and "userId" = @userId
|
||||
`);
|
||||
|
||||
export function updateNote(args: {
|
||||
note: GroupMember["note"];
|
||||
groupId: GroupMember["groupId"];
|
||||
userId: GroupMember["userId"];
|
||||
}) {
|
||||
stm.run(args);
|
||||
}
|
||||
|
|
@ -70,6 +70,8 @@ import { currentOrPreviousSeason } from "~/features/mmr/season";
|
|||
import { Chat, useChat } from "~/components/Chat";
|
||||
import { NewTabs } from "~/components/NewTabs";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import { updateNote } from "../queries/updateNote.server";
|
||||
import { GroupLeaver } from "../components/GroupLeaver";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
|
|
@ -272,6 +274,16 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
|
||||
break;
|
||||
}
|
||||
case "UPDATE_NOTE": {
|
||||
updateNote({
|
||||
note: data.value,
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
|
|
@ -499,6 +511,9 @@ function Groups() {
|
|||
trustedPlayers={data.trustedPlayers}
|
||||
/>
|
||||
) : null}
|
||||
<GroupLeaver
|
||||
type={ownGroup.members.length === 1 ? "LEAVE_Q" : "LEAVE_GROUP"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.ser
|
|||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import { userHasSkill } from "../queries/userHasSkill.server";
|
||||
import { currentSeason } from "~/features/mmr";
|
||||
import { GroupLeaver } from "../components/GroupLeaver";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
|
|
@ -144,7 +145,7 @@ export default function QPreparingPage() {
|
|||
return (
|
||||
<Main className="stack lg items-center">
|
||||
<div className="q-preparing__card-container">
|
||||
<GroupCard group={data.group} ownRole={data.role} ownGroup />
|
||||
<GroupCard group={data.group} ownRole={data.role} ownGroup hideNote />
|
||||
</div>
|
||||
{data.group.members.length < FULL_GROUP_SIZE &&
|
||||
hasGroupManagerPerms(data.role) ? (
|
||||
|
|
@ -162,6 +163,9 @@ export default function QPreparingPage() {
|
|||
Join the queue
|
||||
</SubmitButton>
|
||||
</joinQFetcher.Form>
|
||||
<GroupLeaver
|
||||
type={data.group.members.length === 1 ? "GO_BACK" : "LEAVE_GROUP"}
|
||||
/>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,12 @@ a {
|
|||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
:is(button, .button).miniscule {
|
||||
font-size: var(--fonts-xxs);
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
:is(button, .button).big {
|
||||
font-size: var(--fonts-md);
|
||||
padding-block: var(--s-2-5);
|
||||
|
|
|
|||
5
migrations/038-group-member-note.js
Normal file
5
migrations/038-group-member-note.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module.exports.up = function (db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(/* sql */ `alter table "GroupMember" add "note" text`).run();
|
||||
})();
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user