diff --git a/app/features/components-showcase/form-examples-schema.ts b/app/features/components-showcase/form-examples-schema.ts index 393772305..cfc67727d 100644 --- a/app/features/components-showcase/form-examples-schema.ts +++ b/app/features/components-showcase/form-examples-schema.ts @@ -6,6 +6,7 @@ import { datetimeRequired, dayMonthYearRequired, dualSelectOptional, + image, numberFieldOptional, radioGroup, select, @@ -149,6 +150,15 @@ export const formFieldsShowcaseSchema = z.object({ label: "labels.banUserPlayer", }), + // Image fields + logo: image({ + label: "labels.logo", + }), + banner: image({ + label: "labels.banner", + dimensions: "thick-banner", + }), + // Custom field customValue: customField( { initialValue: "custom initial value" }, diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 8ad485d7e..3391b29d3 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -2147,6 +2147,16 @@ function FormFieldsSection({ id }: { id: string }) { + Image Fields + + + + + + + + + Custom Field diff --git a/app/features/img-upload/ImageRepository.server.test.ts b/app/features/img-upload/ImageRepository.server.test.ts index 5ee9f37ee..74520b008 100644 --- a/app/features/img-upload/ImageRepository.server.test.ts +++ b/app/features/img-upload/ImageRepository.server.test.ts @@ -397,6 +397,49 @@ describe("countUnvalidatedBySubmitterUserId", () => { }); }); +describe("countAllUnvalidatedBySubmitterUserId", () => { + beforeEach(async () => { + imageCounter = 0; + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("counts unvalidated images not associated with any entity", async () => { + await createImage({ submitterUserId: 1 }); + await createImage({ submitterUserId: 1 }); + + const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1); + + expect(count).toBe(2); + }); + + test("does not count validated images", async () => { + await createImage({ submitterUserId: 1, validatedAt: Date.now() }); + + const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1); + + expect(count).toBe(0); + }); + + test("does not count images from other submitters", async () => { + await createImage({ submitterUserId: 1 }); + await createImage({ submitterUserId: 2 }); + + const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1); + + expect(count).toBe(1); + }); + + test("returns 0 when user has no unvalidated images", async () => { + const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1); + + expect(count).toBe(0); + }); +}); + describe("validateImage", () => { beforeEach(async () => { imageCounter = 0; diff --git a/app/features/img-upload/ImageRepository.server.ts b/app/features/img-upload/ImageRepository.server.ts index d37c4230c..244b77fcc 100644 --- a/app/features/img-upload/ImageRepository.server.ts +++ b/app/features/img-upload/ImageRepository.server.ts @@ -1,4 +1,6 @@ +import type { Transaction } from "kysely"; import { db } from "~/db/sql"; +import type { DB, TablesInsertable } from "~/db/tables"; import { databaseTimestampNow } from "~/utils/dates"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; import { IMAGES_TO_VALIDATE_AT_ONCE } from "./upload-constants"; @@ -121,6 +123,21 @@ export async function countUnvalidatedBySubmitterUserId(userId: number) { return result.count; } +/** + * Counts every unvalidated image submitted by a user, regardless of whether it is yet associated + * with an entity. Unlike {@link countUnvalidatedBySubmitterUserId} (team-joined), this also counts + * not-yet-connected orphans, so it can gate the SendouForm `image()` upload path. + */ +export async function countAllUnvalidatedBySubmitterUserId(userId: number) { + const result = await db + .selectFrom("UnvalidatedUserSubmittedImage") + .select(({ fn }) => fn.countAll().as("count")) + .where("validatedAt", "is", null) + .where("submitterUserId", "=", userId) + .executeTakeFirstOrThrow(); + return result.count; +} + /** Marks an image as validated by setting the current timestamp */ export function validateImage(id: number) { return db @@ -130,6 +147,20 @@ export function validateImage(id: number) { .execute(); } +/** + * Inserts an unvalidated image row without associating it with any owner. Returns the inserted row. + */ +export function insert( + args: TablesInsertable["UnvalidatedUserSubmittedImage"], + trx: Transaction | typeof db = db, +) { + return trx + .insertInto("UnvalidatedUserSubmittedImage") + .values(args) + .returningAll() + .executeTakeFirstOrThrow(); +} + /** Creates a new image and associates it with a team or organization */ export function addNewImage({ submitterUserId, @@ -147,11 +178,7 @@ export function addNewImage({ type: ImageUploadType; }) { return db.transaction().execute(async (trx) => { - const img = await trx - .insertInto("UnvalidatedUserSubmittedImage") - .values({ submitterUserId, url, validatedAt }) - .returningAll() - .executeTakeFirstOrThrow(); + const img = await insert({ submitterUserId, url, validatedAt }, trx); if (type === "team-pfp" && teamId) { await trx diff --git a/app/features/img-upload/image-field.server.ts b/app/features/img-upload/image-field.server.ts new file mode 100644 index 000000000..3e15764f8 --- /dev/null +++ b/app/features/img-upload/image-field.server.ts @@ -0,0 +1,77 @@ +import { basename } from "node:path"; +import { Readable } from "node:stream"; +import type { AuthenticatedUser } from "~/features/auth/core/user.server"; +import type { ImageFieldValue } from "~/form/image-field"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { shortNanoid } from "~/utils/id"; +import invariant from "~/utils/invariant"; +import { errorToastIfFalsy } from "~/utils/remix.server"; +import * as ImageRepository from "./ImageRepository.server"; +import { uploadStreamToS3 } from "./s3.server"; +import { MAX_UNVALIDATED_IMG_COUNT } from "./upload-constants"; + +/** + * Resolves a SendouForm `image` field value to the image id to store on the consuming FK column. + * + * - `null` → `null` (image removed / none) + * - `EXISTING` → the unchanged `imgId` (no bytes are re-uploaded) + * - `NEW` → decodes the base64 webp, uploads it to S3 and inserts an unvalidated image row, + * auto-validating it for supporters, then returns the new id. + * + * The consuming action is responsible for writing the returned value to its own FK column. + */ +export async function imageFieldValueToImgId({ + value, + user, +}: { + value: ImageFieldValue; + user: AuthenticatedUser; +}): Promise { + if (!value) return null; + if (value.type === "EXISTING") return value.imgId; + + errorToastIfFalsy( + (await ImageRepository.countAllUnvalidatedBySubmitterUserId(user.id)) < + MAX_UNVALIDATED_IMG_COUNT, + "Too many unvalidated images", + ); + + const buffer = dataUrlToWebpBuffer(value.dataUrl); + + const uploadedFileLocation = await uploadStreamToS3( + Readable.from(buffer), + `img-${Date.now()}-${shortNanoid()}.webp`, + ); + invariant(uploadedFileLocation, "Image upload failed"); + const fileName = basename(uploadedFileLocation); + + const shouldAutoValidate = user.roles.includes("SUPPORTER"); + + const img = await ImageRepository.insert({ + submitterUserId: user.id, + url: fileName, + validatedAt: shouldAutoValidate + ? dateToDatabaseTimestamp(new Date()) + : null, + }); + + return img.id; +} + +function dataUrlToWebpBuffer(dataUrl: string) { + const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1); + const buffer = Buffer.from(base64, "base64"); + + invariant(isWebp(buffer), "Submitted image is not a valid webp"); + + return buffer; +} + +/** Verifies the buffer's magic bytes match the webp container (`RIFF....WEBP`). */ +function isWebp(buffer: Buffer) { + return ( + buffer.length > 12 && + buffer.toString("ascii", 0, 4) === "RIFF" && + buffer.toString("ascii", 8, 12) === "WEBP" + ); +} diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 2b16bcf4d..8a1991494 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -112,6 +112,8 @@ export function findByCustomUrl( "Team.tag", "Team.customUrl", "Team.customTheme", + "Team.avatarImgId", + "Team.bannerImgId", concatUserSubmittedImagePrefix(eb.ref("AvatarImage.url")).as("avatarUrl"), concatUserSubmittedImagePrefix(eb.ref("BannerImage.url")).as("bannerUrl"), jsonArrayFrom( @@ -309,23 +311,53 @@ export async function update({ bio, bsky, tag, -}: Pick, "id" | "name" | "bio" | "bsky" | "tag">) { + avatarImgId, + bannerImgId, +}: Pick< + Insertable, + "id" | "name" | "bio" | "bsky" | "tag" | "avatarImgId" | "bannerImgId" +>) { const customUrl = mySlugify(name); - const team = await db - .updateTable("AllTeam") - .set({ - name, - customUrl, - bio, - bsky, - tag, - }) - .where("id", "=", id) - .returningAll() - .executeTakeFirstOrThrow(); + return db.transaction().execute(async (trx) => { + const current = await trx + .selectFrom("Team") + .select(["avatarImgId", "bannerImgId"]) + .where("id", "=", id) + .executeTakeFirst(); - return team; + // images that got removed or replaced are no longer referenced by anything, + // so their submitted image rows are cleaned up + const orphanedImageIds: number[] = []; + if (current?.avatarImgId && current.avatarImgId !== avatarImgId) { + orphanedImageIds.push(current.avatarImgId); + } + if (current?.bannerImgId && current.bannerImgId !== bannerImgId) { + orphanedImageIds.push(current.bannerImgId); + } + + if (orphanedImageIds.length > 0) { + await trx + .deleteFrom("UnvalidatedUserSubmittedImage") + .where("id", "in", orphanedImageIds) + .execute(); + } + + return trx + .updateTable("AllTeam") + .set({ + name, + customUrl, + bio, + bsky, + tag, + avatarImgId, + bannerImgId, + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirstOrThrow(); + }); } export async function updateCustomTheme({ @@ -422,37 +454,6 @@ export function del(teamId: number) { }); } -export function removeTeamImage( - teamId: number, - imageType: "avatar" | "banner", -) { - const imageIdField = imageType === "avatar" ? "avatarImgId" : "bannerImgId"; - - return db.transaction().execute(async (trx) => { - const team = await trx - .selectFrom("Team") - .select(imageIdField) - .where("id", "=", teamId) - .executeTakeFirst(); - - const imageId = team?.[imageIdField]; - if (imageId) { - await trx - .deleteFrom("UnvalidatedUserSubmittedImage") - .where("id", "=", imageId) - .execute(); - } - - await trx - .updateTable("AllTeam") - .set({ - [imageIdField]: null, - }) - .where("id", "=", teamId) - .execute(); - }); -} - export function resetInviteCode(teamId: number) { return db .updateTable("AllTeam") diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts index 3cb64917f..67ac42c8b 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.test.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -3,15 +3,10 @@ import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; import { db } from "~/db/sql"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import { clampThemeToGamut } from "~/utils/oklch-gamut"; -import { - assertResponseErrored, - dbInsertUsers, - dbReset, - wrappedAction, -} from "~/utils/Test"; +import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; import { action as teamIndexPageAction } from "../actions/t.new.server"; import type { createTeamSchema } from "../team-schemas"; -import type { editTeamSchema } from "../team-schemas.server"; +import type { editTeamActionSchema } from "../team-schemas.server"; import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server"; const createTeamAction = wrappedAction({ @@ -19,7 +14,7 @@ const createTeamAction = wrappedAction({ isJsonSubmission: true, }); -const editTeamProfileAction = wrappedAction({ +const editTeamProfileAction = wrappedAction({ action: _editTeamProfileAction, isJsonSubmission: true, }); @@ -30,6 +25,8 @@ const DEFAULT_EDIT_FIELDS = { bio: "", bsky: "", tag: "", + logo: null, + banner: null, } as const; const VALID_CUSTOM_THEME = { @@ -122,7 +119,7 @@ describe("team page editing", () => { { user: "regular", params: { customUrl: "team-1" } }, ); - assertResponseErrored(response); + expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy(); }); it("preserves an existing custom theme when editing the team profile", async () => { @@ -147,4 +144,65 @@ describe("team page editing", () => { expect(team?.customTheme).toEqual(expectedStoredTheme()); expect(team?.bio).toBe("Updated bio"); }); + + const addTeamAvatar = async () => { + const image = await db + .insertInto("UnvalidatedUserSubmittedImage") + .values({ + url: "https://example.com/test-avatar.jpg", + submitterUserId: REGULAR_USER_TEST_ID, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await db + .updateTable("AllTeam") + .set({ avatarImgId: image.id }) + .where("customUrl", "=", "team-1") + .execute(); + + return image.id; + }; + + const imageExists = async (id: number) => + Boolean( + await db + .selectFrom("UnvalidatedUserSubmittedImage") + .select("id") + .where("id", "=", id) + .executeTakeFirst(), + ); + + it("deletes the submitted image row when an image is removed while editing", async () => { + const imageId = await addTeamAvatar(); + + await editTeamProfileAction( + { ...DEFAULT_EDIT_FIELDS }, + { user: "regular", params: { customUrl: "team-1" } }, + ); + + const team = await TeamRepository.findByCustomUrl("team-1"); + expect(team?.avatarImgId).toBeNull(); + expect(await imageExists(imageId)).toBe(false); + }); + + it("keeps the submitted image row when an existing image is unchanged", async () => { + const imageId = await addTeamAvatar(); + + await editTeamProfileAction( + { + ...DEFAULT_EDIT_FIELDS, + logo: { + type: "EXISTING", + imgId: imageId, + url: "https://example.com/test-avatar.jpg", + }, + }, + { user: "regular", params: { customUrl: "team-1" } }, + ); + + const team = await TeamRepository.findByCustomUrl("team-1"); + expect(team?.avatarImgId).toBe(imageId); + expect(await imageExists(imageId)).toBe(true); + }); }); diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index f05856b49..899729752 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -1,21 +1,14 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; +import { parseFormDataWithImages } from "~/form/parse.server"; import { clampThemeToGamut } from "~/utils/oklch-gamut"; -import { - errorToastIfFalsy, - notFoundIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; +import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { mySlugify, teamPage } from "~/utils/urls"; import * as TeamRepository from "../TeamRepository.server"; -import { editTeamSchema, teamParamsSchema } from "../team-schemas.server"; -import { - canAddCustomizedColors, - isTeamManager, - isTeamOwner, -} from "../team-utils"; +import { editTeamActionSchema, teamParamsSchema } from "../team-schemas.server"; +import { canAddCustomizedColors, isTeamManager } from "../team-utils"; export const action: ActionFunction = async ({ request, params }) => { const user = requireUser(); @@ -28,18 +21,17 @@ export const action: ActionFunction = async ({ request, params }) => { "You are not a team manager", ); - const data = await parseRequestPayload({ + const result = await parseFormDataWithImages({ request, - schema: editTeamSchema, + schema: editTeamActionSchema, }); - if (data._action.includes("DELETE")) { - errorToastIfFalsy( - isTeamOwner({ team, user }) || user.roles.includes("ADMIN"), - "You are not the team owner", - ); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; } + const data = result.data; + switch (data._action) { case "UPDATE_CUSTOM_THEME": { errorToastIfFalsy( @@ -47,49 +39,32 @@ export const action: ActionFunction = async ({ request, params }) => { "Team does not have custom theme access", ); - const customTheme = data.newValue - ? clampThemeToGamut(data.newValue) - : null; - await TeamRepository.updateCustomTheme({ id: team.id, - customTheme, + customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null, }); return { ok: true }; } - case "DELETE_TEAM": { - await TeamRepository.del(team.id); - throw redirect("/"); - } - case "DELETE_AVATAR": { - await TeamRepository.removeTeamImage(team.id, "avatar"); - throw redirect(teamPage(team.customUrl)); - } - case "DELETE_BANNER": { - await TeamRepository.removeTeamImage(team.id, "banner"); - throw redirect(teamPage(team.customUrl)); - } case "EDIT": { const newCustomUrl = mySlugify(data.name); - - errorToastIfFalsy( - newCustomUrl.length > 0, - "Team name can't be only special characters", - ); - const teams = await TeamRepository.findAllUndisbanded(); const duplicateTeam = teams.find( (t) => t.customUrl === newCustomUrl && t.customUrl !== team.customUrl, ); if (duplicateTeam) { - return { errors: ["forms:errors.duplicateName"] }; + return { fieldErrors: { name: "forms:errors.duplicateName" } }; } const updatedTeam = await TeamRepository.update({ id: team.id, - ...data, + name: data.name, + bio: data.bio, + bsky: data.bsky, + tag: data.tag, + avatarImgId: data.logo, + bannerImgId: data.banner, }); throw redirect(teamPage(updatedTeam.customUrl)); diff --git a/app/features/team/actions/t.$customUrl.index.server.ts b/app/features/team/actions/t.$customUrl.index.server.ts index b38393b28..64d1ba9b9 100644 --- a/app/features/team/actions/t.$customUrl.index.server.ts +++ b/app/features/team/actions/t.$customUrl.index.server.ts @@ -1,4 +1,5 @@ import type { ActionFunction } from "react-router"; +import { redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import { errorToastIfFalsy, @@ -54,6 +55,15 @@ export const action: ActionFunction = async ({ request, params }) => { break; } + case "DELETE_TEAM": { + errorToastIfFalsy( + isTeamOwner({ user, team }) || user.roles.includes("ADMIN"), + "You are not the team owner", + ); + + await TeamRepository.del(team.id); + throw redirect("/"); + } default: { assertUnreachable(data); } diff --git a/app/features/team/routes/t.$customUrl.edit.test.ts b/app/features/team/routes/t.$customUrl.edit.test.ts index 2e7feced9..45d8827ca 100644 --- a/app/features/team/routes/t.$customUrl.edit.test.ts +++ b/app/features/team/routes/t.$customUrl.edit.test.ts @@ -1,27 +1,25 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - assertResponseErrored, - dbInsertUsers, - dbReset, - wrappedAction, -} from "~/utils/Test"; +import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; import { action as teamIndexPageAction } from "../actions/t.new.server"; import { action as _editTeamAction } from "../routes/t.$customUrl.edit"; -import type { createTeamSchema } from "../team-schemas"; -import type { editTeamSchema } from "../team-schemas.server"; +import type { createTeamSchema, editTeamFormSchema } from "../team-schemas"; const createTeamAction = wrappedAction({ action: teamIndexPageAction, isJsonSubmission: true, }); -const editTeamAction = wrappedAction({ +const editTeamAction = wrappedAction({ action: _editTeamAction, isJsonSubmission: true, }); const DEFAULT_FIELDS = { + tag: null, + bsky: null, bio: null, + logo: null, + banner: null, } as any; describe("team creation", () => { @@ -45,13 +43,13 @@ describe("team creation", () => { { user: "regular", params: { customUrl: "team-1" } }, ); - expect(res.errors[0]).toBe("forms:errors.duplicateName"); + expect(res.fieldErrors.name).toBe("forms:errors.duplicateName"); }); it("prevents editing team name to only special characters", async () => { await createTeamAction({ name: "Team 1" }, { user: "regular" }); - const response = await editTeamAction( + const res = await editTeamAction( { _action: "EDIT", name: "𝓢𝓲𝓵", @@ -60,6 +58,6 @@ describe("team creation", () => { { user: "regular", params: { customUrl: "team-1" } }, ); - assertResponseErrored(response); + expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters"); }); }); diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index a35d5d9ae..79f2185af 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -1,27 +1,17 @@ -import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Form, Link, useFetcher, useLoaderData } from "react-router"; +import { useFetcher, useLoaderData } from "react-router"; import { CustomThemeSelector } from "~/components/CustomThemeSelector"; import { Divider } from "~/components/Divider"; -import { SendouButton } from "~/components/elements/Button"; -import { FormErrors } from "~/components/FormErrors"; -import { FormMessage } from "~/components/FormMessage"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { Input } from "~/components/Input"; -import { Label } from "~/components/Label"; import { Main, mainStyles } from "~/components/Main"; -import { SubmitButton } from "~/components/SubmitButton"; -import { useUser } from "~/features/auth/core/user"; import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; +import { SendouForm } from "~/form/SendouForm"; import type { ThemeInput } from "~/utils/oklch-gamut"; import { metaTags } from "~/utils/remix"; -import { uploadImagePage } from "~/utils/urls"; import { action } from "../actions/t.$customUrl.edit.server"; import { loader } from "../loaders/t.$customUrl.edit.server"; import styles from "../team.module.css"; -import { TEAM } from "../team-constants"; -import { isTeamOwner } from "../team-utils"; +import { editTeamFormSchema } from "../team-schemas"; export { action, loader }; @@ -34,43 +24,51 @@ export const meta: MetaFunction = (args) => { export default function EditTeamPage() { const { t } = useTranslation(["common", "team"]); - const user = useUser(); const { team, canAddCustomizedColors } = useLoaderData(); return (
- {isTeamOwner({ team, user }) ? ( - - - {t("team:actionButtons.deleteTeam")} - - - ) : null} -
- - - - - - - - {t("common:actions.submit")} - - - + + {({ FormField }) => ( + <> + + + + + + + + )} + {canAddCustomizedColors ? ( <> @@ -84,171 +82,6 @@ export default function EditTeamPage() { ); } -function ImageUploadLinks() { - const { t } = useTranslation(["team"]); - const { team } = useLoaderData(); - - return ( -
- -
    -
  1. - - {t("team:forms.fields.uploadImages.pfp")} - -
  2. -
  3. - - {t("team:forms.fields.uploadImages.banner")} - -
  4. -
-
- ); -} - -function ImageRemoveButtons() { - const { t } = useTranslation(["common", "team"]); - const { team } = useLoaderData(); - - return team.avatarUrl || team.bannerUrl ? ( -
- -
    - {team.avatarUrl ? ( -
  1. - - - {t("team:actionButtons.deleteTeam.profilePicture")} - - -
  2. - ) : null} - {team.bannerUrl ? ( -
  3. - - - {t("team:actionButtons.deleteTeam.banner")} - - -
  4. - ) : null} -
-
- ) : null; -} - -function NameInput() { - const { t } = useTranslation(["common", "team"]); - const { team } = useLoaderData(); - - return ( -
- - - {t("team:forms.info.name")} -
- ); -} - -function TagInput() { - const { t } = useTranslation(["team"]); - const { team } = useLoaderData(); - const [value, setValue] = React.useState(team.tag ?? ""); - - return ( -
- - setValue(e.target.value)} - /> - {t("team:forms.info.tag")} -
- ); -} - -function BlueskyInput() { - const { t } = useTranslation(["team"]); - const { team } = useLoaderData(); - const [value, setValue] = React.useState(team.bsky ?? ""); - - return ( -
- - setValue(e.target.value)} - /> -
- ); -} - -function BioTextarea() { - const { t } = useTranslation(["team"]); - const { team } = useLoaderData(); - const [value, setValue] = React.useState(team.bio ?? ""); - - return ( -
- -