Bluesky for user & team pages (#1891)

* Migrations

* Bluesky for team page

* Unify interfaces

* For user page

* To org social links
This commit is contained in:
Kalle 2024-09-28 10:43:49 +03:00 committed by GitHub
parent 803b93e061
commit 59d77642e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 198 additions and 60 deletions

View File

@ -0,0 +1,16 @@
export function BskyIcon({ className }: { className?: string }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 568 501"
>
<title>Bluesky butterfly logo</title>
<path
fill="currentColor"
d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"
/>
</svg>
);
}

View File

@ -8,6 +8,7 @@ export const USER = {
CUSTOM_URL_MAX_LENGTH: 32,
CUSTOM_NAME_MAX_LENGTH: 32,
BATTLEFY_MAX_LENGTH: 32,
BSKY_MAX_LENGTH: 50,
IN_GAME_NAME_TEXT_MAX_LENGTH: 20,
IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH: 5,
WEAPON_POOL_MAX_SIZE: 5,

View File

@ -20,7 +20,9 @@ export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export interface AllTeam {
export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number];
export interface Team {
avatarImgId: number | null;
bannerImgId: number | null;
bio: string | null;
@ -32,11 +34,10 @@ export interface AllTeam {
inviteCode: string;
name: string;
twitter: string | null;
bsky: string | null;
}
export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number];
export interface AllTeamMember {
export interface TeamMember {
createdAt: Generated<number>;
isOwner: Generated<number>;
leftAt: number | null;
@ -46,20 +47,6 @@ export interface AllTeamMember {
isMainTeam: number;
}
export interface Team {
avatarImgId: number | null;
bannerImgId: number | null;
bio: string | null;
createdAt: number | null;
css: ColumnType<Record<string, string> | null, string | null, string | null>;
customUrl: string;
deletedAt: number | null;
id: GeneratedAlways<number>;
inviteCode: string;
name: string;
twitter: string | null;
}
export interface Art {
authorId: number;
createdAt: Generated<number>;
@ -759,6 +746,7 @@ export interface User {
stickSens: number | null;
twitch: string | null;
twitter: string | null;
bsky: string | null;
battlefy: string | null;
vc: Generated<"YES" | "NO" | "LISTEN_ONLY">;
youtubeId: string | null;
@ -848,8 +836,8 @@ export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
export interface DB {
AllTeam: AllTeam;
AllTeamMember: AllTeamMember;
AllTeam: Team;
AllTeamMember: TeamMember;
Art: Art;
ArtTag: ArtTag;
ArtUserMetadata: ArtUserMetadata;
@ -887,8 +875,8 @@ export interface DB {
SplatoonPlayer: SplatoonPlayer;
TaggedArt: TaggedArt;
Team: Team;
TeamMember: AllTeamMember;
TeamMemberWithSecondary: AllTeamMember;
TeamMember: TeamMember;
TeamMemberWithSecondary: TeamMember;
Tournament: Tournament;
TournamentStaff: TournamentStaff;
TournamentBadgeOwner: TournamentBadgeOwner;

View File

@ -67,6 +67,7 @@ export function findByCustomUrl(customUrl: string) {
"Team.id",
"Team.name",
"Team.twitter",
"Team.bsky",
"Team.bio",
"Team.customUrl",
"Team.css",
@ -140,6 +141,33 @@ export async function create(
});
}
export async function update({
id,
name,
customUrl,
bio,
twitter,
bsky,
css,
}: Pick<
Insertable<Tables["Team"]>,
"id" | "name" | "customUrl" | "bio" | "twitter" | "bsky"
> & { css: string | null }) {
return db
.updateTable("AllTeam")
.set({
name,
customUrl,
bio,
twitter,
bsky,
css,
})
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
}
export function switchMainTeam({
userId,
teamId,

View File

@ -1,32 +0,0 @@
import { sql } from "~/db/sql";
import type { Team } from "~/db/types";
const stm = sql.prepare(/*sql*/ `
update "AllTeam"
set
"name" = @name,
"customUrl" = @customUrl,
"bio" = @bio,
"twitter" = @twitter,
"css" = @css
where "id" = @id
returning *
`);
export function edit({
id,
name,
customUrl,
bio,
twitter,
css,
}: Pick<Team, "id" | "name" | "customUrl" | "bio" | "twitter" | "css">) {
return stm.get({
id,
name,
customUrl,
bio,
twitter,
css,
}) as Team;
}

View File

@ -37,7 +37,6 @@ import {
} from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import { edit } from "../queries/edit.server";
import { TEAM } from "../team-constants";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
@ -106,7 +105,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
}
const editedTeam = edit({
const editedTeam = await TeamRepository.update({
id: team.id,
customUrl: newCustomUrl,
...data,
@ -158,6 +157,7 @@ export default function EditTeamPage() {
) : null}
<NameInput />
<TwitterInput />
<BlueskyInput />
<BioTextarea />
<SubmitButton
className="mt-4"
@ -249,6 +249,26 @@ function TwitterInput() {
);
}
function BlueskyInput() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(team.bsky ?? "");
return (
<div>
<Label htmlFor="bsky">{t("team:forms.fields.teamBsky")}</Label>
<Input
leftAddon="https://bsky.app/profile/"
id="bsky"
name="bsky"
maxLength={TEAM.BSKY_MAX_LENGTH}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
);
}
function BioTextarea() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();

View File

@ -10,6 +10,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
import { WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { BskyIcon } from "~/components/icons/Bsky";
import { EditIcon } from "~/components/icons/Edit";
import { StarIcon } from "~/components/icons/Star";
import { TwitterIcon } from "~/components/icons/Twitter";
@ -21,6 +22,7 @@ import type { SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import {
TEAM_SEARCH_PAGE,
bskyUrl,
editTeamPage,
manageTeamRosterPage,
navIconUrl,
@ -126,7 +128,7 @@ function TeamBanner() {
})}
</div>
<div className="team__banner__name">
{team.name} <TwitterLink testId="twitter-link" />
{team.name} <TwitterLink testId="twitter-link" /> <BskyLink />
</div>
</div>
{team.avatarSrc ? <div className="team__banner__avatar__spacer" /> : null}
@ -151,6 +153,7 @@ function MobileTeamNameCountry() {
<div className="team__mobile-team-name">
{team.name}
<TwitterLink />
<BskyLink />
</div>
</div>
);
@ -174,6 +177,23 @@ function TwitterLink({ testId }: { testId?: string }) {
);
}
function BskyLink() {
const { team } = useLoaderData<typeof loader>();
if (!team.bsky) return null;
return (
<a
className="team__bsky-link"
href={bskyUrl(team.bsky)}
target="_blank"
rel="noreferrer"
>
<BskyIcon />
</a>
);
}
function ActionButtons() {
const { t } = useTranslation(["team"]);
const user = useUser();

View File

@ -3,6 +3,7 @@ export const TEAM = {
NAME_MIN_LENGTH: 2,
BIO_MAX_LENGTH: 2000,
TWITTER_MAX_LENGTH: 50,
BSKY_MAX_LENGTH: 50,
MAX_MEMBER_COUNT: 10,
MAX_TEAM_COUNT_NON_PATRON: 2,
MAX_TEAM_COUNT_PATRON: 5,

View File

@ -32,6 +32,10 @@ export const editTeamSchema = z.union([
falsyToNull,
z.string().max(TEAM.TWITTER_MAX_LENGTH).nullable(),
),
bsky: z.preprocess(
falsyToNull,
z.string().max(TEAM.BSKY_MAX_LENGTH).nullable(),
),
css: z.preprocess(falsyToNull, z.string().refine(jsonParseable).nullable()),
}),
]);

View File

@ -97,6 +97,26 @@
fill: #1da1f2;
}
.team__bsky-link {
padding: var(--s-1);
border: 1px solid;
border-radius: 50%;
border-color: #1285fe;
background-color: #1285fe2f;
height: 24.4px;
width: 24.4px;
display: grid;
place-items: center;
}
.team__bsky-link > svg {
width: 0.9rem;
}
.team__bsky-link path {
fill: #1285fe;
}
.team__banner__avatar {
grid-area: avatar;
align-self: flex-end;

View File

@ -1,4 +1,5 @@
import clsx from "clsx";
import { BskyIcon } from "~/components/icons/Bsky";
import { LinkIcon } from "~/components/icons/Link";
import { TwitchIcon } from "~/components/icons/Twitch";
import { TwitterIcon } from "~/components/icons/Twitter";
@ -24,6 +25,7 @@ function SocialLink({ url }: { url: string }) {
youtube: type === "youtube",
twitter: type === "twitter",
twitch: type === "twitch",
bsky: type === "bsky",
})}
>
<SocialLinkIcon url={url} />
@ -48,6 +50,10 @@ function SocialLinkIcon({ url }: { url: string }) {
return <YouTubeIcon />;
}
if (type === "bsky") {
return <BskyIcon />;
}
return <LinkIcon />;
}
@ -64,5 +70,9 @@ const urlToLinkType = (url: string) => {
return "youtube";
}
if (url.includes("bsky.app")) {
return "bsky";
}
return null;
};

View File

@ -181,3 +181,7 @@
.org__social-link__icon-container.twitter svg {
fill: #1da1f2;
}
.org__social-link__icon-container.bsky path {
fill: #1285fe;
}

View File

@ -140,6 +140,7 @@ export async function findProfileByIdentifier(
"User.twitter",
"User.youtubeId",
"User.battlefy",
"User.bsky",
"User.country",
"User.bio",
"User.motionSens",
@ -586,6 +587,7 @@ type UpdateProfileArgs = Pick<
| "stickSens"
| "inGameName"
| "battlefy"
| "bsky"
| "css"
| "favoriteBadgeId"
| "showDiscordUniqueName"
@ -628,6 +630,7 @@ export function updateProfile(args: UpdateProfileArgs) {
inGameName: args.inGameName,
css: args.css,
battlefy: args.battlefy,
bsky: args.bsky,
favoriteBadgeId: args.favoriteBadgeId,
showDiscordUniqueName: args.showDiscordUniqueName,
commissionText: args.commissionText,

View File

@ -91,6 +91,10 @@ const userEditActionSchema = z
falsyToNull,
z.string().max(USER.BATTLEFY_MAX_LENGTH).nullable(),
),
bsky: z.preprocess(
falsyToNull,
z.string().max(USER.BSKY_MAX_LENGTH).nullable(),
),
stickSens: z.preprocess(
processMany(actualNumber, undefinedToNull),
z
@ -257,6 +261,7 @@ export default function UserEditPage() {
<InGameNameInputs />
<SensSelects />
<BattlefyInput />
<BskyInput />
<CountrySelect />
<FavBadgeSelect />
<WeaponPoolSelect />
@ -455,6 +460,24 @@ function BattlefyInput() {
);
}
function BskyInput() {
const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
return (
<div className="w-full">
<Label htmlFor="bsky">{t("user:bsky")}</Label>
<Input
name="bsky"
id="bsky"
maxLength={USER.BSKY_MAX_LENGTH}
defaultValue={data.user.bsky ?? undefined}
leftAddon="https://bsky.app/profile/"
/>
</div>
);
}
function WeaponPoolSelect() {
const data = useLoaderData<typeof loader>();
const [weapons, setWeapons] = React.useState(data.user.weapons);

View File

@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { Image, WeaponImage } from "~/components/Image";
import { Popover } from "~/components/Popover";
import { BattlefyIcon } from "~/components/icons/Battlefy";
import { BskyIcon } from "~/components/icons/Bsky";
import { DiscordIcon } from "~/components/icons/Discord";
import { TwitchIcon } from "~/components/icons/Twitch";
import { TwitterIcon } from "~/components/icons/Twitter";
@ -17,6 +19,7 @@ import type { SendouRouteHandle } from "~/utils/remix";
import { rawSensToString } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
bskyUrl,
modeImageUrl,
navIconUrl,
teamPage,
@ -25,7 +28,6 @@ import {
} from "~/utils/urls";
import type { UserPageLoaderData } from "./u.$identifier";
import { Popover } from "~/components/Popover";
import { loader } from "../loaders/u.$identifier.index.server";
export { loader };
@ -67,6 +69,9 @@ export default function UserInfoPage() {
{data.user.battlefy ? (
<SocialLink type="battlefy" identifier={data.user.battlefy} />
) : null}
{data.user.bsky ? (
<SocialLink type="bsky" identifier={data.user.bsky} />
) : null}
</div>
</div>
<BannedInfo />
@ -168,7 +173,7 @@ function SecondaryTeamsPopover() {
}
interface SocialLinkProps {
type: "youtube" | "twitter" | "twitch" | "battlefy";
type: "youtube" | "twitter" | "twitch" | "battlefy" | "bsky";
identifier: string;
}
@ -176,7 +181,7 @@ export function SocialLink({
type,
identifier,
}: {
type: "youtube" | "twitter" | "twitch" | "battlefy";
type: SocialLinkProps["type"];
identifier: string;
}) {
const href = () => {
@ -189,6 +194,8 @@ export function SocialLink({
return `https://www.youtube.com/channel/${identifier}`;
case "battlefy":
return `https://battlefy.com/users/${identifier}`;
case "bsky":
return bskyUrl(identifier);
default:
assertUnreachable(type);
}
@ -201,6 +208,7 @@ export function SocialLink({
twitter: type === "twitter",
twitch: type === "twitch",
battlefy: type === "battlefy",
bsky: type === "bsky",
})}
href={href()}
>
@ -219,6 +227,8 @@ function SocialLinkIcon({ type }: Pick<SocialLinkProps, "type">) {
return <YouTubeIcon />;
case "battlefy":
return <BattlefyIcon />;
case "bsky":
return <BskyIcon />;
default:
assertUnreachable(type);
}

View File

@ -97,6 +97,17 @@
fill: #de4c5e;
}
.u__social-link.bsky {
border-color: #1285fe;
background-color: #1285fe2f;
display: grid;
place-items: center;
}
.u__social-link.bsky path {
fill: #1285fe;
}
.u__extra-infos {
display: flex;
max-width: 24rem;

View File

@ -73,6 +73,8 @@ export const SPR_INFO_URL =
export const twitterUrl = (accountName: string) =>
`https://twitter.com/${accountName}`;
export const bskyUrl = (accountName: string) =>
`https://bsky.app/profile/${accountName}`;
export const twitchUrl = (accountName: string) =>
`https://twitch.tv/${accountName}`;

View File

@ -27,6 +27,7 @@
"roles.COACH": "Coach",
"roles.CHEERLEADER": "Cheerleader",
"forms.fields.teamTwitter": "Team Twitter",
"forms.fields.teamBsky": "Team Bluesky",
"forms.fields.bio": "Bio",
"forms.fields.uploadImages": "Upload images",
"forms.fields.uploadImages.pfp": "Profile Picture",

View File

@ -14,6 +14,7 @@
"discordExplanation": "Username, profile picture, YouTube, Twitter and Twitch accounts come from your Discord account. See <1>FAQ</1> for more information.",
"favoriteBadge": "Favorite Badge",
"battlefy": "Battlefy account name",
"bsky": "Bluesky account name",
"forms.showDiscordUniqueName": "Show Discord username",
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",

7
migrations/070-bsky.js Normal file
View File

@ -0,0 +1,7 @@
export function up(db) {
db.transaction(() => {
db.prepare(/* sql */ `alter table "User" add "bsky" text`).run();
db.prepare(/* sql */ `alter table "AllTeam" add "bsky" text`).run();
})();
}