diff --git a/app/db/tables.ts b/app/db/tables.ts index f2a02b9b4..0e0a4bd57 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1229,6 +1229,15 @@ export interface TournamentStreamer { twitchAccount: string; } +export interface ExternalStream { + id: GeneratedAlways; + name: string; + url: string; + avatarImgId: number | null; + startTime: number; + createdAt: Generated; +} + export interface TournamentMatchVod { id: GeneratedAlways; matchId: number; @@ -1473,6 +1482,7 @@ export interface DB { CalendarEventDate: CalendarEventDate; CalendarEventResultPlayer: CalendarEventResultPlayer; CalendarEventResultTeam: CalendarEventResultTeam; + ExternalStream: ExternalStream; Group: Group; GroupLike: GroupLike; diff --git a/app/features/admin/ExternalStreamRepository.server.ts b/app/features/admin/ExternalStreamRepository.server.ts new file mode 100644 index 000000000..6b52261d8 --- /dev/null +++ b/app/features/admin/ExternalStreamRepository.server.ts @@ -0,0 +1,80 @@ +import { db } from "~/db/sql"; +import type { TablesInsertable } from "~/db/tables"; +import { databaseTimestampNow } from "~/utils/dates"; +import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; + +/** Number of seconds an external stream keeps showing in the sidebar after its start time. */ +const SIDEBAR_VISIBLE_SECONDS = 6 * 60 * 60; +/** Number of seconds after the start time before an external stream row is deleted. */ +const RETENTION_SECONDS = 24 * 60 * 60; + +/** Inserts a new admin-curated external stream. */ +export function insert( + args: Pick< + TablesInsertable["ExternalStream"], + "name" | "url" | "avatarImgId" | "startTime" + >, +) { + return db.insertInto("ExternalStream").values(args).execute(); +} + +/** Deletes an external stream by its id. */ +export function deleteById(id: number) { + return db.deleteFrom("ExternalStream").where("id", "=", id).execute(); +} + +/** Lists all external streams (for the admin management page), soonest start time first. */ +export function all() { + return db + .selectFrom("ExternalStream") + .leftJoin( + "UserSubmittedImage", + "UserSubmittedImage.id", + "ExternalStream.avatarImgId", + ) + .select((eb) => [ + "ExternalStream.id", + "ExternalStream.name", + "ExternalStream.url", + "ExternalStream.startTime", + concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( + "avatarUrl", + ), + ]) + .orderBy("ExternalStream.startTime", "asc") + .execute(); +} + +/** External streams that should currently show in the sidebar (started under 6h ago or upcoming). */ +export function forSidebar() { + return db + .selectFrom("ExternalStream") + .leftJoin( + "UserSubmittedImage", + "UserSubmittedImage.id", + "ExternalStream.avatarImgId", + ) + .select((eb) => [ + "ExternalStream.id", + "ExternalStream.name", + "ExternalStream.url", + "ExternalStream.startTime", + concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( + "avatarUrl", + ), + ]) + .where( + "ExternalStream.startTime", + ">=", + databaseTimestampNow() - SIDEBAR_VISIBLE_SECONDS, + ) + .execute(); +} + +/** Deletes external streams whose start time is more than 24h in the past. */ +export function deleteOld() { + return db + .deleteFrom("ExternalStream") + .where("startTime", "<", databaseTimestampNow() - RETENTION_SECONDS) + .executeTakeFirst(); +} diff --git a/app/features/admin/actions/admin.streams.server.ts b/app/features/admin/actions/admin.streams.server.ts new file mode 100644 index 000000000..c5aac042b --- /dev/null +++ b/app/features/admin/actions/admin.streams.server.ts @@ -0,0 +1,46 @@ +import type { ActionFunctionArgs } from "react-router"; +import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server"; +import { parseFormDataWithImages } from "~/form/parse.server"; +import { requireRole } from "~/modules/permissions/guards.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { assertUnreachable } from "~/utils/types"; +import { externalStreamActionSchema } from "../admin-schemas"; +import * as ExternalStreamRepository from "../ExternalStreamRepository.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + requireRole("ADMIN"); + + const result = await parseFormDataWithImages({ + request, + schema: externalStreamActionSchema, + }); + + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + const data = result.data; + + switch (data._action) { + case "CREATE": { + await ExternalStreamRepository.insert({ + name: data.name, + url: data.url, + avatarImgId: data.avatar, + startTime: dateToDatabaseTimestamp(data.startTime), + }); + break; + } + case "DELETE": { + await ExternalStreamRepository.deleteById(data.id); + break; + } + default: { + assertUnreachable(data); + } + } + + clearCombinedStreamsCache(); + + return null; +}; diff --git a/app/features/admin/admin-schemas.ts b/app/features/admin/admin-schemas.ts index 42c5cccf8..bc587733e 100644 --- a/app/features/admin/admin-schemas.ts +++ b/app/features/admin/admin-schemas.ts @@ -1,6 +1,34 @@ import { z } from "zod"; -import { friendCode } from "~/utils/zod"; +import { + datetimeRequired, + image, + stringConstant, + textFieldRequired, +} from "~/form/fields"; +import { friendCode, id } from "~/utils/zod"; export const adminActionSearchParamsSchema = z.object({ friendCode, }); + +export const createExternalStreamSchema = z.object({ + _action: stringConstant("CREATE"), + name: textFieldRequired({ label: "labels.name", maxLength: 64 }), + url: textFieldRequired({ + label: "labels.link", + maxLength: 200, + validate: "url", + }), + avatar: image({ label: "labels.logo", autoValidate: true }), + startTime: datetimeRequired({ label: "labels.startTime" }), +}); + +const deleteExternalStreamSchema = z.object({ + _action: stringConstant("DELETE"), + id, +}); + +export const externalStreamActionSchema = z.union([ + createExternalStreamSchema, + deleteExternalStreamSchema, +]); diff --git a/app/features/admin/loaders/admin.streams.server.ts b/app/features/admin/loaders/admin.streams.server.ts new file mode 100644 index 000000000..404edafc7 --- /dev/null +++ b/app/features/admin/loaders/admin.streams.server.ts @@ -0,0 +1,10 @@ +import { requireRole } from "~/modules/permissions/guards.server"; +import * as ExternalStreamRepository from "../ExternalStreamRepository.server"; + +export const loader = async () => { + requireRole("ADMIN"); + + return { + streams: await ExternalStreamRepository.all(), + }; +}; diff --git a/app/features/admin/routes/admin.streams.module.css b/app/features/admin/routes/admin.streams.module.css new file mode 100644 index 000000000..a0a6b6568 --- /dev/null +++ b/app/features/admin/routes/admin.streams.module.css @@ -0,0 +1,12 @@ +.streamRow { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.streamAvatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 100%; + object-fit: cover; +} diff --git a/app/features/admin/routes/admin.streams.tsx b/app/features/admin/routes/admin.streams.tsx new file mode 100644 index 000000000..9297aa26f --- /dev/null +++ b/app/features/admin/routes/admin.streams.tsx @@ -0,0 +1,98 @@ +import type { MetaFunction } from "react-router"; +import { Link, useLoaderData } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { LocaleTime } from "~/components/LocaleTime"; +import { Main } from "~/components/Main"; +import { SendouForm } from "~/form/SendouForm"; +import { metaTags } from "~/utils/remix"; +import { action } from "../actions/admin.streams.server"; +import { createExternalStreamSchema } from "../admin-schemas"; +import { loader } from "../loaders/admin.streams.server"; + +import styles from "./admin.streams.module.css"; + +export { action, loader }; + +export const meta: MetaFunction = (args) => { + return metaTags({ + title: "External streams", + location: args.location, + }); +}; + +export default function AdminStreamsPage() { + return ( +
+ + {({ FormField }) => ( + <> + + + + + + )} + + +
+ ); +} + +function ExternalStreamList() { + const { streams } = useLoaderData(); + + if (streams.length === 0) { + return
No external streams
; + } + + return ( +
+

Current external streams

+
    + {streams.map((stream) => ( +
  • + {stream.avatarUrl ? ( + + ) : null} +
    + + {stream.name} + + + + +
    + + + Delete + + +
  • + ))} +
+
+ ); +} diff --git a/app/features/sidebar/core/StreamRanking.ts b/app/features/sidebar/core/StreamRanking.ts index 270bc079d..e866e74a2 100644 --- a/app/features/sidebar/core/StreamRanking.ts +++ b/app/features/sidebar/core/StreamRanking.ts @@ -4,6 +4,12 @@ import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; type RankedStream = { stream: SidebarStream; score: number }; +/** + * Score for admin-curated external streams. Below every other source's minimum (0) so they sort + * to the top of the ranking, reserving the first sidebar slots. + */ +export const EXTERNAL_STREAM_SCORE = -1; + export function rank( streams: RankedStream[], maxStreams: number, diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index bbc558530..fd45284d9 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -2,6 +2,7 @@ import { cachified } from "@epic-web/cachified"; import { addDays } from "date-fns"; import { href } from "react-router"; import * as R from "remeda"; +import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server"; import { userIsBanned } from "~/features/ban/core/banned.server"; import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types"; import { @@ -137,11 +138,13 @@ function combinedStreamsCached(): Promise { async function combinedStreams(): Promise { const tournamentStreams = getLiveTournamentStreams(); - const [sendouQEntries, xRankRows, upcomingTournaments] = await Promise.all([ - getSendouQSidebarStreams(), - LiveStreamRepository.findXRankStreams(), - ShowcaseTournaments.upcomingTournaments(), - ]); + const [sendouQEntries, xRankRows, upcomingTournaments, externalStreams] = + await Promise.all([ + getSendouQSidebarStreams(), + LiveStreamRepository.findXRankStreams(), + ShowcaseTournaments.upcomingTournaments(), + ExternalStreamRepository.forSidebar(), + ]); const seenUsernames = new Set([ ...getLiveTournamentStreamerTwitchNames(), @@ -152,6 +155,21 @@ async function combinedStreams(): Promise { const ranked: { stream: SidebarStream; score: number }[] = []; + for (const externalStream of externalStreams) { + ranked.push({ + stream: { + id: `external-${externalStream.id}`, + name: externalStream.name, + imageUrl: externalStream.avatarUrl ?? BLANK_IMAGE_URL, + url: externalStream.url, + subtitle: "", + startsAt: externalStream.startTime, + tier: null, + }, + score: StreamRanking.EXTERNAL_STREAM_SCORE, + }); + } + for (const stream of tournamentStreams) { ranked.push({ stream, diff --git a/app/routes.ts b/app/routes.ts index 820965d09..29aba62e3 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -268,6 +268,7 @@ export default [ ]), route("/admin", "features/admin/routes/admin.tsx"), + route("/admin/streams", "features/admin/routes/admin.streams.tsx"), route("/api/chat-users", "features/chat/routes/api.chat-users.ts"), route("/room", "features/chat/routes/room.ts"), route("/api", "features/api/routes/api.tsx"), diff --git a/app/routines/deleteOldExternalStreams.ts b/app/routines/deleteOldExternalStreams.ts new file mode 100644 index 000000000..9374f8817 --- /dev/null +++ b/app/routines/deleteOldExternalStreams.ts @@ -0,0 +1,11 @@ +import * as ExternalStreamRepository from "../features/admin/ExternalStreamRepository.server"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +export const DeleteOldExternalStreamsRoutine = new Routine({ + name: "DeleteOldExternalStreams", + func: async () => { + const { numDeletedRows } = await ExternalStreamRepository.deleteOld(); + logger.info(`Deleted ${numDeletedRows} old external streams`); + }, +}); diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index fb413fd51..4ad57e7da 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -1,6 +1,7 @@ import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions"; import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods"; +import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams"; import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications"; import { DeleteOldRoomLinksRoutine } from "./deleteOldRoomLinks"; import { DeleteOldTournamentAuditLogsRoutine } from "./deleteOldTournamentAuditLogs"; @@ -32,6 +33,7 @@ export const everyHourAt30 = [ UpdatePatreonDataRoutine, CloseExpiredContinueVotesRoutine, DeleteOldRoomLinksRoutine, + DeleteOldExternalStreamsRoutine, ]; /** List of Routines that should occur daily */ diff --git a/db-test.sqlite3 b/db-test.sqlite3 index f6b2f5d6e..a892ccf06 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 59efa13d8..f0308e405 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 28aac23b9..50e1cb3e5 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 5dc58a85c..9389e6b4c 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index 155d00f4f..86f33e846 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 95b1b688d..a546fa2ab 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 0423e3335..f30434fc2 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 6ee68595e..57da51081 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 16acdca1a..8b4aac003 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 345578b39..15004ba87 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index fd768aa5e..38d3cdc10 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index a2b7f6525..75dbe8d7d 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/sendouq-match.spec.ts b/e2e/sendouq-match.spec.ts index f6381ac93..25004cae4 100644 --- a/e2e/sendouq-match.spec.ts +++ b/e2e/sendouq-match.spec.ts @@ -293,8 +293,21 @@ async function selectMapWinner(page: Page, winner: "ALPHA" | "BRAVO") { page.locator('[data-testid^="winner-radio-"][data-selected="true"]'), ).toHaveCount(0); // react-aria's Radio renders a hidden input behind a span overlay; click the - // wrapping label so the press handler fires and updates winnerId. - await page.locator(`label:has(input[aria-label="${teamName}"])`).click(); + // wrapping label so the press handler fires and updates winnerId. The press + // occasionally registers a press-start without a press-end (same React Aria + // nondeterminism as in waitForPOSTResponse), so the selection silently drops + // and Submit stays disabled. Re-issue the click until the radio reports + // selected; otherwise the Submit-click retry loop spins on a disabled button. + const label = page.locator(`label:has(input[aria-label="${teamName}"])`); + const radio = page.locator( + `[data-testid^="winner-radio-"]:has(input[aria-label="${teamName}"])`, + ); + await expect(async () => { + await label.click(); + await expect(radio).toHaveAttribute("data-selected", "true", { + timeout: 1_000, + }); + }).toPass(); } async function voteNo(page: Page) { diff --git a/locales/da/forms.json b/locales/da/forms.json index c5aa793f2..84fb5f477 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Biografi", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 179bcfc77..9cc811d4e 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Über mich", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index c78592c38..c10e781b1 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Bio", "labels.logo": "Logo", "labels.banner": "Banner", + "labels.link": "Link", "labels.tag": "Tag", "labels.teamBsky": "Team Bluesky", "labels.teamEditor": "Editor", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 2b5d22cb2..1221ec9b9 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Biografía", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "Etiqueta", "labels.teamBsky": "Bluesky del equipo", "labels.teamEditor": "Editor", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index f06b68d4a..e08a2e0f2 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Biografía", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 598d023c6..4a80aa433 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Bio", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index fd62b058e..7c8423edf 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Bio", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "Team Bluesky", "labels.teamEditor": "Editer", diff --git a/locales/he/forms.json b/locales/he/forms.json index 1288f6f06..690637309 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -4,6 +4,7 @@ "labels.bio": "ביו", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 9d347f9aa..5a3d216f0 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Biografia", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "Bluesky del team", "labels.teamEditor": "Editor", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index 6bdf243d0..fdafe8228 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -4,6 +4,7 @@ "labels.bio": "自己紹介", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "チームの Bluesky", "labels.teamEditor": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 47ea0b265..b856f8858 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -4,6 +4,7 @@ "labels.bio": "소개", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index bdcbcde78..b0de304cb 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Bio", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 246312467..a0f9e012b 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Opis", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index a0f4f61a2..28b6fc315 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Bio", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 98e1771ba..c8c57675b 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -4,6 +4,7 @@ "labels.bio": "Описание", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "Bluesky команды", "labels.teamEditor": "Редактор", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 703224dd8..e09a546ee 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -4,6 +4,7 @@ "labels.bio": "简介", "labels.logo": "", "labels.banner": "", + "labels.link": "", "labels.tag": "", "labels.teamBsky": "", "labels.teamEditor": "", diff --git a/migrations/152-external-stream.js b/migrations/152-external-stream.js new file mode 100644 index 000000000..6c5c7ee5f --- /dev/null +++ b/migrations/152-external-stream.js @@ -0,0 +1,19 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "ExternalStream" ( + "id" integer primary key autoincrement, + "name" text not null, + "url" text not null, + "avatarImgId" integer, + "startTime" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("avatarImgId") references "UnvalidatedUserSubmittedImage"("id") on delete set null + ) strict + `, + ).run(); + + db.pragma("foreign_key_check"); + })(); +}