mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-13 04:54:00 -05:00
External streams
This commit is contained in:
@@ -1229,6 +1229,15 @@ export interface TournamentStreamer {
|
||||
twitchAccount: string;
|
||||
}
|
||||
|
||||
export interface ExternalStream {
|
||||
id: GeneratedAlways<number>;
|
||||
name: string;
|
||||
url: string;
|
||||
avatarImgId: number | null;
|
||||
startTime: number;
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
export interface TournamentMatchVod {
|
||||
id: GeneratedAlways<number>;
|
||||
matchId: number;
|
||||
@@ -1473,6 +1482,7 @@ export interface DB {
|
||||
CalendarEventDate: CalendarEventDate;
|
||||
CalendarEventResultPlayer: CalendarEventResultPlayer;
|
||||
CalendarEventResultTeam: CalendarEventResultTeam;
|
||||
ExternalStream: ExternalStream;
|
||||
|
||||
Group: Group;
|
||||
GroupLike: GroupLike;
|
||||
|
||||
80
app/features/admin/ExternalStreamRepository.server.ts
Normal file
80
app/features/admin/ExternalStreamRepository.server.ts
Normal file
@@ -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();
|
||||
}
|
||||
46
app/features/admin/actions/admin.streams.server.ts
Normal file
46
app/features/admin/actions/admin.streams.server.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
10
app/features/admin/loaders/admin.streams.server.ts
Normal file
10
app/features/admin/loaders/admin.streams.server.ts
Normal file
@@ -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(),
|
||||
};
|
||||
};
|
||||
12
app/features/admin/routes/admin.streams.module.css
Normal file
12
app/features/admin/routes/admin.streams.module.css
Normal file
@@ -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;
|
||||
}
|
||||
98
app/features/admin/routes/admin.streams.tsx
Normal file
98
app/features/admin/routes/admin.streams.tsx
Normal file
@@ -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 (
|
||||
<Main className="stack lg">
|
||||
<SendouForm
|
||||
schema={createExternalStreamSchema}
|
||||
title="Add external stream"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="name" />
|
||||
<FormField name="url" />
|
||||
<FormField name="avatar" />
|
||||
<FormField name="startTime" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
<ExternalStreamList />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalStreamList() {
|
||||
const { streams } = useLoaderData<typeof loader>();
|
||||
|
||||
if (streams.length === 0) {
|
||||
return <div className="text-lighter">No external streams</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<h2>Current external streams</h2>
|
||||
<ul className="stack sm">
|
||||
{streams.map((stream) => (
|
||||
<li key={stream.id} className={styles.streamRow}>
|
||||
{stream.avatarUrl ? (
|
||||
<img
|
||||
src={stream.avatarUrl}
|
||||
alt=""
|
||||
className={styles.streamAvatar}
|
||||
/>
|
||||
) : null}
|
||||
<div className="stack xxs">
|
||||
<Link to={stream.url} className="text-main-forced">
|
||||
{stream.name}
|
||||
</Link>
|
||||
<span className="text-xs text-lighter">
|
||||
<LocaleTime
|
||||
date={stream.startTime}
|
||||
inline
|
||||
options={{
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<FormWithConfirm
|
||||
dialogHeading={`Delete external stream "${stream.name}"?`}
|
||||
fields={[
|
||||
["_action", "DELETE"],
|
||||
["id", stream.id],
|
||||
]}
|
||||
>
|
||||
<SendouButton variant="minimal-destructive" className="ml-auto">
|
||||
Delete
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SidebarStream[]> {
|
||||
|
||||
async function combinedStreams(): Promise<SidebarStream[]> {
|
||||
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<SidebarStream[]> {
|
||||
|
||||
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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
11
app/routines/deleteOldExternalStreams.ts
Normal file
11
app/routines/deleteOldExternalStreams.ts
Normal file
@@ -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`);
|
||||
},
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Biografi",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Über mich",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Biografía",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "Team Bluesky",
|
||||
"labels.teamEditor": "Editer",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "ביו",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Biografia",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "Bluesky del team",
|
||||
"labels.teamEditor": "Editor",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "自己紹介",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "チームの Bluesky",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "소개",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Opis",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "Описание",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "Bluesky команды",
|
||||
"labels.teamEditor": "Редактор",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"labels.bio": "简介",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.teamEditor": "",
|
||||
|
||||
19
migrations/152-external-stream.js
Normal file
19
migrations/152-external-stream.js
Normal file
@@ -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");
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user