Add player bug reports and profile identity features
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { glowColorSchema } from "./profile-appearance";
|
||||
|
||||
export const displayNameSchema = z
|
||||
.string()
|
||||
@@ -38,8 +39,15 @@ export const authActionSchema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("recover"), email }).strict(),
|
||||
]);
|
||||
export const profileUpdateSchema = z
|
||||
.object({ displayName: displayNameSchema })
|
||||
.strict();
|
||||
.object({
|
||||
displayName: displayNameSchema.optional(),
|
||||
glowColor: glowColorSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => value.displayName !== undefined || value.glowColor !== undefined,
|
||||
"Choose a profile change",
|
||||
);
|
||||
export const initialPasswordSchema = z
|
||||
.object({ password: newPasswordSchema })
|
||||
.strict();
|
||||
@@ -61,10 +69,12 @@ export function authDestination(value: string | null | undefined): string {
|
||||
value === "/settings" ||
|
||||
value === "/dashboard" ||
|
||||
value === "/servers" ||
|
||||
value === "/support" ||
|
||||
value === "/auth/reset-password"
|
||||
)
|
||||
return value;
|
||||
if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value;
|
||||
if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value;
|
||||
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
|
||||
return "/dashboard";
|
||||
}
|
||||
|
||||
103
shared/bug-reports.ts
Normal file
103
shared/bug-reports.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
import { displayNameSchema } from "./account";
|
||||
|
||||
export const screenshotLimit = 5 * 1024 * 1024;
|
||||
const text = (min: number, max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(min)
|
||||
.max(max)
|
||||
.refine(
|
||||
(value) =>
|
||||
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
|
||||
value,
|
||||
),
|
||||
"Remove hidden control characters",
|
||||
);
|
||||
export const bugReportSchema = z
|
||||
.object({
|
||||
clientId: z.string().uuid(),
|
||||
title: text(5, 120),
|
||||
description: text(15, 6000),
|
||||
displayName: displayNameSchema.optional(),
|
||||
pagePath: z
|
||||
.string()
|
||||
.max(300)
|
||||
.regex(/^\/(?!\/)[a-zA-Z0-9/_-]*$/)
|
||||
.default("/"),
|
||||
screenshot: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
export const bugMessageSchema = z
|
||||
.object({ clientId: z.string().uuid(), text: text(1, 6000) })
|
||||
.strict();
|
||||
export type BugReportRecord = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
owner_id: string | null;
|
||||
access_hash: string;
|
||||
display_name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
page_path: string;
|
||||
state: "draft" | "pending" | "open";
|
||||
screenshot_expected: boolean;
|
||||
screenshot_path: string | null;
|
||||
linear_asset_url: string | null;
|
||||
linear_identifier: string | null;
|
||||
issue_synced: boolean;
|
||||
glow_requested: boolean;
|
||||
bug_confirmed?: boolean;
|
||||
lease_owner: string | null;
|
||||
lease_until: string | null;
|
||||
retry_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
export type BugMessageRecord = {
|
||||
id: string;
|
||||
client_id: string | null;
|
||||
report_id: string;
|
||||
kind: "user" | "staff" | "system";
|
||||
body: string;
|
||||
author_name: string;
|
||||
delivered: boolean;
|
||||
created_at: string;
|
||||
external_id: string | null;
|
||||
};
|
||||
export type BugReportView = Pick<
|
||||
BugReportRecord,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "page_path"
|
||||
| "state"
|
||||
| "linear_identifier"
|
||||
| "glow_requested"
|
||||
| "created_at"
|
||||
> & {
|
||||
screenshot: string | null;
|
||||
isGuest: boolean;
|
||||
authorAppearance?: import("./profile-appearance").ProfileAppearance;
|
||||
canClaim: boolean;
|
||||
messages: Pick<
|
||||
BugMessageRecord,
|
||||
"id" | "kind" | "body" | "author_name" | "delivered" | "created_at"
|
||||
>[];
|
||||
};
|
||||
export type SupportCommand =
|
||||
| { kind: "reply"; text: string }
|
||||
| { kind: "glow"; enabled: boolean };
|
||||
export function supportCommand(body: string): SupportCommand | null {
|
||||
const reply = /^#player-report-send\s+([\s\S]+)$/.exec(body.trim());
|
||||
if (reply) {
|
||||
const parsed = text(1, 6000).safeParse(reply[1]);
|
||||
return parsed.success ? { kind: "reply", text: parsed.data } : null;
|
||||
}
|
||||
const glow = /^#player-report-glow (grant|revoke)$/.exec(body.trim());
|
||||
return glow ? { kind: "glow", enabled: glow[1] === "grant" } : null;
|
||||
}
|
||||
export function escapeSupportMarkdown(value: string) {
|
||||
return value.replace(/[\\`*_{}\[\]()<>#!|~]/g, "\\$&");
|
||||
}
|
||||
@@ -43,6 +43,30 @@ export type Database = {
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
bug_reports: {
|
||||
Row: import("./bug-reports").BugReportRecord;
|
||||
Insert: Partial<import("./bug-reports").BugReportRecord> &
|
||||
Pick<
|
||||
import("./bug-reports").BugReportRecord,
|
||||
| "client_id"
|
||||
| "access_hash"
|
||||
| "display_name"
|
||||
| "title"
|
||||
| "description"
|
||||
>;
|
||||
Update: Partial<import("./bug-reports").BugReportRecord>;
|
||||
Relationships: [];
|
||||
};
|
||||
bug_report_messages: {
|
||||
Row: import("./bug-reports").BugMessageRecord;
|
||||
Insert: Partial<import("./bug-reports").BugMessageRecord> &
|
||||
Pick<
|
||||
import("./bug-reports").BugMessageRecord,
|
||||
"report_id" | "kind" | "body" | "author_name"
|
||||
>;
|
||||
Update: Partial<import("./bug-reports").BugMessageRecord>;
|
||||
Relationships: [];
|
||||
};
|
||||
multiplayer_messages: {
|
||||
Row: {
|
||||
id: number;
|
||||
@@ -115,6 +139,7 @@ export type Database = {
|
||||
};
|
||||
multiplayer_players: {
|
||||
Row: {
|
||||
account_id: string | null;
|
||||
id: string;
|
||||
room_id: string;
|
||||
access_hash: string;
|
||||
@@ -124,6 +149,7 @@ export type Database = {
|
||||
joined_at: string;
|
||||
};
|
||||
Insert: {
|
||||
account_id?: string | null;
|
||||
id?: string;
|
||||
room_id: string;
|
||||
access_hash: string;
|
||||
@@ -132,7 +158,11 @@ export type Database = {
|
||||
has_left?: boolean;
|
||||
joined_at?: string;
|
||||
};
|
||||
Update: { ready_round?: number; has_left?: boolean };
|
||||
Update: {
|
||||
ready_round?: number;
|
||||
has_left?: boolean;
|
||||
account_id?: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
multiplayer_guesses: {
|
||||
@@ -453,18 +483,33 @@ export type Database = {
|
||||
};
|
||||
profiles: {
|
||||
Row: {
|
||||
glow_color: string;
|
||||
bug_finder: boolean;
|
||||
custom_prefix: string | null;
|
||||
custom_prefix_icon: string;
|
||||
name_glow: boolean;
|
||||
created_at: string;
|
||||
display_name: string;
|
||||
id: string;
|
||||
role: string;
|
||||
};
|
||||
Insert: {
|
||||
glow_color?: string;
|
||||
bug_finder?: boolean;
|
||||
custom_prefix?: string | null;
|
||||
custom_prefix_icon?: string;
|
||||
name_glow?: boolean;
|
||||
created_at?: string;
|
||||
display_name?: string;
|
||||
id: string;
|
||||
role?: string;
|
||||
};
|
||||
Update: {
|
||||
glow_color?: string;
|
||||
bug_finder?: boolean;
|
||||
custom_prefix?: string | null;
|
||||
custom_prefix_icon?: string;
|
||||
name_glow?: boolean;
|
||||
created_at?: string;
|
||||
display_name?: string;
|
||||
id?: string;
|
||||
@@ -650,6 +695,33 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
set_user_prefix: {
|
||||
Args: {
|
||||
p_reviewer: string;
|
||||
p_user: string;
|
||||
p_prefix: string | null;
|
||||
p_icon: string;
|
||||
};
|
||||
Returns: undefined;
|
||||
};
|
||||
claim_bug_sync: {
|
||||
Args: { p_report: string; p_worker: string };
|
||||
Returns: boolean;
|
||||
};
|
||||
receive_bug_reply: {
|
||||
Args: {
|
||||
p_report: string;
|
||||
p_external: string;
|
||||
p_author: string;
|
||||
p_body: string;
|
||||
p_glow: boolean | null;
|
||||
};
|
||||
Returns: boolean;
|
||||
};
|
||||
claim_bug_report: {
|
||||
Args: { p_report: string; p_hash: string; p_owner: string };
|
||||
Returns: undefined;
|
||||
};
|
||||
create_map_import: {
|
||||
Args: {
|
||||
p_owner: string;
|
||||
|
||||
@@ -5,14 +5,16 @@ export const leaderboardDifficulties = [
|
||||
"hard",
|
||||
] as const;
|
||||
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
|
||||
export type LeaderboardEntry = {
|
||||
rank: number;
|
||||
player_id: string;
|
||||
display_name: string;
|
||||
best_score: number;
|
||||
average_score: number;
|
||||
games_played: number;
|
||||
};
|
||||
export type LeaderboardEntry =
|
||||
import("./profile-appearance").ProfileAppearance & {
|
||||
rank: number;
|
||||
player_id: string;
|
||||
display_name: string;
|
||||
name_glow?: boolean;
|
||||
best_score: number;
|
||||
average_score: number;
|
||||
games_played: number;
|
||||
};
|
||||
export type LeaderboardData = {
|
||||
entries: LeaderboardEntry[];
|
||||
leaders: LeaderboardEntry[];
|
||||
|
||||
@@ -714,5 +714,77 @@
|
||||
"Chat permission requires joining this room": "Чтобы пользоваться чатом, войдите в эту комнату",
|
||||
"Chat is unavailable in a closed or expired room": "Чат недоступен в закрытой комнате или комнате с истёкшим сроком",
|
||||
"Too many chat messages. Wait a moment and try again.": "Слишком много сообщений. Подождите немного и попробуйте снова.",
|
||||
"Invalid chat message": "Недопустимое сообщение чата"
|
||||
"Invalid chat message": "Недопустимое сообщение чата",
|
||||
"Send bug report": "Сообщить об ошибке",
|
||||
"Report a bug": "Сообщить об ошибке",
|
||||
"Moderator": "Модератор",
|
||||
"Bug finder": "Нашёл ошибку",
|
||||
"Username glow": "Свечение имени",
|
||||
"Choose the color of your glow.": "Выберите цвет свечения вашего имени.",
|
||||
"Support can award a glow for a confirmed bug report.": "Поддержка может подарить свечение за подтверждённую ошибку.",
|
||||
"Glow color": "Цвет свечения",
|
||||
"Save glow color": "Сохранить цвет свечения",
|
||||
"Glow color saved.": "Цвет свечения сохранён.",
|
||||
"Earn a username glow before choosing its color": "Сначала получите свечение имени, чтобы выбрать его цвет",
|
||||
"Choose a valid glow color": "Выберите допустимый цвет свечения",
|
||||
"Player prefixes": "Префиксы игроков",
|
||||
"Find a player and assign a custom prefix with an icon.": "Найдите игрока и выдайте ему свой префикс с иконкой.",
|
||||
"Player name or account ID": "Имя игрока или ID аккаунта",
|
||||
"Find players": "Найти игроков",
|
||||
"No players found.": "Игроки не найдены.",
|
||||
"Custom prefix": "Свой префикс",
|
||||
"Prefix icon": "Иконка префикса",
|
||||
"Save prefix": "Сохранить префикс",
|
||||
"Remove prefix": "Убрать префикс",
|
||||
"Player prefix saved.": "Префикс игрока сохранён.",
|
||||
"Use a prefix of 1–24 characters without hidden controls or markup": "Используйте от 1 до 24 символов без скрытых управляющих символов и разметки",
|
||||
"Star": "Звезда",
|
||||
"Crown": "Корона",
|
||||
"Heart": "Сердце",
|
||||
"Flame": "Огонь",
|
||||
"Gem": "Самоцвет",
|
||||
"Trophy": "Кубок",
|
||||
"Sparkles": "Искры",
|
||||
"Player not found": "Игрок не найден",
|
||||
"PLAYER SUPPORT": "ПОДДЕРЖКА ИГРОКОВ",
|
||||
"Found a bug?": "Нашли ошибку?",
|
||||
"Tell us what happened. We’ll reply here.": "Расскажите, что произошло. Мы ответим здесь.",
|
||||
"Guest reports stay in this browser. Sign in to keep your conversation in your account.": "Обращения гостей сохраняются в этом браузере. Войдите, чтобы сохранить переписку в аккаунте.",
|
||||
"Bug reporting is temporarily unavailable. Please try again later.": "Отправка обращений временно недоступна. Попробуйте позже.",
|
||||
"Your player name": "Ваше имя игрока",
|
||||
"Bug title": "Название ошибки",
|
||||
"What happened?": "Что произошло?",
|
||||
"What did you do, what did you expect, and what happened instead?": "Что вы сделали, чего ожидали и что произошло вместо этого?",
|
||||
"Screenshot (optional)": "Скриншот (необязательно)",
|
||||
"PNG, JPG, or WebP, up to 5 MB. Shared privately with support.": "PNG, JPG или WebP, до 5 МБ. Доступен только вам и поддержке.",
|
||||
"Your bug reports": "Ваши обращения",
|
||||
"Your reports and support replies will appear here.": "Здесь появятся ваши обращения и ответы поддержки.",
|
||||
"Draft": "Черновик",
|
||||
"Waiting for support": "Ожидает поддержки",
|
||||
"← Your bug reports": "← Ваши обращения",
|
||||
"Your report is saved. We’re connecting it to support.": "Обращение сохранено. Передаём его поддержке.",
|
||||
"This report is not sent yet. Return to the form or finish sending it.": "Обращение ещё не отправлено. Вернитесь к форме или завершите отправку.",
|
||||
"Finish sending report": "Завершить отправку",
|
||||
"Keep this report in my account": "Сохранить обращение в аккаунте",
|
||||
"Support awarded you a username glow.": "Поддержка подарила вам свечение имени.",
|
||||
"Account settings": "Настройки аккаунта",
|
||||
"For guest reports, sign in and link this report to receive your reward.": "Если вы отправили обращение как гость, войдите и привяжите его к аккаунту, чтобы получить награду.",
|
||||
"Attached bug screenshot": "Прикреплённый скриншот ошибки",
|
||||
"Support conversation": "Переписка с поддержкой",
|
||||
"Support": "Поддержка",
|
||||
"Delivered to support": "Доставлено поддержке",
|
||||
"Saved · waiting to send": "Сохранено · ожидает отправки",
|
||||
"Message support": "Написать поддержке",
|
||||
"Username glow granted.": "Свечение имени выдано.",
|
||||
"Username glow removed.": "Свечение имени отключено.",
|
||||
"Add a short title, at least 15 characters describing the bug, and your player name.": "Добавьте короткое название, описание ошибки от 15 символов и своё имя игрока.",
|
||||
"Choose a PNG, JPG, or WebP screenshot smaller than 5 MB": "Выберите скриншот PNG, JPG или WebP размером до 5 МБ",
|
||||
"Screenshot upload failed. Please try again.": "Не удалось загрузить скриншот. Попробуйте ещё раз.",
|
||||
"Use 1–6000 characters without hidden controls": "Используйте от 1 до 6000 символов без скрытых управляющих символов",
|
||||
"Open support before sending a report": "Откройте страницу поддержки перед отправкой обращения",
|
||||
"Report not found": "Обращение не найдено",
|
||||
"Submit this report before sending messages": "Отправьте обращение перед отправкой сообщений",
|
||||
"Invalid support message": "Недопустимое сообщение поддержке",
|
||||
"Sign in to link this report": "Войдите, чтобы привязать обращение",
|
||||
"Remove hidden control characters": "Удалите скрытые управляющие символы"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type RoomChat = {
|
||||
id: string;
|
||||
playerId: string;
|
||||
name: string;
|
||||
appearance?: import("./profile-appearance").ProfileAppearance;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}[];
|
||||
@@ -62,6 +63,7 @@ export type RoomView = {
|
||||
players: {
|
||||
id: string;
|
||||
name: string;
|
||||
appearance?: import("./profile-appearance").ProfileAppearance;
|
||||
left: boolean;
|
||||
ready: boolean;
|
||||
submitted: boolean;
|
||||
|
||||
46
shared/profile-appearance.ts
Normal file
46
shared/profile-appearance.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
export const defaultGlowColor = "#ffe6a1";
|
||||
export const glowColorSchema = z
|
||||
.string()
|
||||
.regex(/^#[a-fA-F0-9]{6}$/, "Choose a valid glow color")
|
||||
.transform((color) => color.toLowerCase());
|
||||
export const prefixIcons = [
|
||||
"star",
|
||||
"crown",
|
||||
"heart",
|
||||
"flame",
|
||||
"gem",
|
||||
"trophy",
|
||||
"sparkles",
|
||||
] as const;
|
||||
export type PrefixIcon = (typeof prefixIcons)[number];
|
||||
export const customPrefixSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(24)
|
||||
.refine(
|
||||
(value) =>
|
||||
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069<>]/u.test(value),
|
||||
"Use a prefix of 1–24 characters without hidden controls or markup",
|
||||
);
|
||||
export const prefixUpdateSchema = z
|
||||
.object({ prefix: customPrefixSchema.nullable(), icon: z.enum(prefixIcons) })
|
||||
.strict();
|
||||
export type ProfileAppearance = {
|
||||
name_glow?: boolean;
|
||||
glow_color?: string;
|
||||
role?: string;
|
||||
bug_finder?: boolean;
|
||||
custom_prefix?: string | null;
|
||||
custom_prefix_icon?: string;
|
||||
};
|
||||
export const appearanceColumns =
|
||||
"name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
||||
export type BadgeUser = ProfileAppearance & {
|
||||
id: string;
|
||||
display_name: string;
|
||||
};
|
||||
export function glowColor(value?: string) {
|
||||
return glowColorSchema.safeParse(value).data ?? defaultGlowColor;
|
||||
}
|
||||
Reference in New Issue
Block a user