feat: release v0.2.0 profiles, chat, freeze, and bots
Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/** Wide profile-header artwork. Palettes come from the avatar studio; these fills stretch them across a 3:1 banner. */
|
||||
|
||||
export const BANNER_WIDTH = 1200;
|
||||
export const BANNER_HEIGHT = 400;
|
||||
|
||||
export type BannerFill = "gradient" | "stripes" | "dots" | "diamonds" | "pixel" | "waves";
|
||||
|
||||
export const BANNER_FILLS: { id: BannerFill; label: string }[] = [
|
||||
{ id: "gradient", label: "گرادیان" },
|
||||
{ id: "stripes", label: "راهراه" },
|
||||
{ id: "dots", label: "نقطهای" },
|
||||
{ id: "diamonds", label: "لوزی" },
|
||||
{ id: "pixel", label: "پیکسلی" },
|
||||
{ id: "waves", label: "موج" },
|
||||
];
|
||||
|
||||
function hash(seed: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function palette(colors: string[]): string[] {
|
||||
return colors.length >= 2 ? colors : ["#7d6df2", "#0f9b8e"];
|
||||
}
|
||||
|
||||
export function drawBannerFill(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
colors: string[],
|
||||
fill: BannerFill,
|
||||
seedKey: string,
|
||||
) {
|
||||
const c = palette(colors);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (fill === "gradient") {
|
||||
const g = ctx.createLinearGradient(0, h, w, 0);
|
||||
c.forEach((color, i) => g.addColorStop(i / (c.length - 1), color));
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const glow = ctx.createRadialGradient(w * 0.5, h * 0.42, h * 0.04, w * 0.5, h * 0.42, w * 0.46);
|
||||
glow.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||
glow.addColorStop(1, "rgba(255,255,255,0)");
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = c[0];
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
if (fill === "stripes") {
|
||||
const stripe = Math.max(14, w / 26);
|
||||
ctx.save();
|
||||
ctx.translate(w / 2, h / 2);
|
||||
ctx.rotate(-Math.PI / 7);
|
||||
const span = w + h;
|
||||
let i = 0;
|
||||
for (let x = -span; x < span; x += stripe) {
|
||||
ctx.globalAlpha = 0.88;
|
||||
ctx.fillStyle = c[i % c.length];
|
||||
ctx.fillRect(x, -span, stripe * 0.58, span * 2);
|
||||
i++;
|
||||
}
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill === "dots") {
|
||||
const gap = Math.max(22, w / 28);
|
||||
const radius = gap * 0.22;
|
||||
let row = 0;
|
||||
for (let y = gap * 0.55; y < h + gap; y += gap * 0.86) {
|
||||
const offset = row % 2 ? gap / 2 : 0;
|
||||
let col = 0;
|
||||
for (let x = offset; x < w + gap; x += gap) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = c[(row + col) % c.length];
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
col++;
|
||||
}
|
||||
row++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill === "diamonds") {
|
||||
const gap = Math.max(28, w / 18);
|
||||
const s = gap * 0.42;
|
||||
let row = 0;
|
||||
for (let y = gap * 0.2; y < h + gap; y += gap * 0.72) {
|
||||
const offset = row % 2 ? gap / 2 : 0;
|
||||
let col = 0;
|
||||
for (let x = offset; x < w + gap; x += gap) {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
ctx.fillStyle = c[(row + col) % c.length];
|
||||
ctx.fillRect(-s / 2, -s / 2, s, s);
|
||||
ctx.restore();
|
||||
col++;
|
||||
}
|
||||
row++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fill === "pixel") {
|
||||
const cols = 18;
|
||||
const rows = 6;
|
||||
const cw = w / cols;
|
||||
const ch = h / rows;
|
||||
const rand = mulberry32(hash(`${seedKey}:pixel`));
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
ctx.fillStyle = c[Math.floor(rand() * c.length)];
|
||||
ctx.fillRect(Math.floor(x * cw), Math.floor(y * ch), Math.ceil(cw) + 1, Math.ceil(ch) + 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bands = c.length;
|
||||
for (let b = 0; b < bands; b++) {
|
||||
const amp = h * (0.055 + (b % 3) * 0.012);
|
||||
const freq = 1.15 + (b % 4) * 0.35;
|
||||
const base = h * (0.16 + (b / bands) * 0.72);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
ctx.lineTo(0, base);
|
||||
for (let x = 0; x <= w; x += 8) {
|
||||
const y = base + Math.sin((x / w) * Math.PI * 2 * freq + b * 0.85) * amp;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
ctx.globalAlpha = b === 0 ? 0.45 : 0.94;
|
||||
ctx.fillStyle = c[b];
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import type { Role, User } from "./types";
|
||||
|
||||
/** Hard cap for students — no timed refill. */
|
||||
export const MAX_HEARTS = 3;
|
||||
/** Absolute student cap. A heart returns on the server every few hours. */
|
||||
export const MAX_HEARTS = 5;
|
||||
|
||||
/** Infinity glyph for staff heart display. */
|
||||
export const INFINITE_HEARTS_LABEL = "∞";
|
||||
|
||||
@@ -169,7 +169,111 @@ export async function saveRemoteFile(
|
||||
triggerDownload(blob, filename);
|
||||
}
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
c ^= data[i];
|
||||
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function uniqueZipName(name: string, used: Map<string, number>): string {
|
||||
const base = (name || "file").split(/[/\\]/).pop() || "file";
|
||||
const clean = base.replace(/[^\w.\-\u0600-\u06FF ]+/g, "_").slice(0, 80) || "file";
|
||||
const n = used.get(clean) ?? 0;
|
||||
used.set(clean, n + 1);
|
||||
if (n === 0) return clean;
|
||||
const dot = clean.lastIndexOf(".");
|
||||
if (dot > 0) return `${clean.slice(0, dot)}-${n + 1}${clean.slice(dot)}`;
|
||||
return `${clean}-${n + 1}`;
|
||||
}
|
||||
|
||||
/** Uncompressed zip so several chat files save as one download. */
|
||||
function buildStoredZip(files: { name: string; data: Uint8Array }[]): Blob {
|
||||
const enc = new TextEncoder();
|
||||
const now = new Date();
|
||||
const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
|
||||
const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
|
||||
const locals: Uint8Array[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
for (const file of files) {
|
||||
const name = enc.encode(file.name);
|
||||
const crc = crc32(file.data);
|
||||
const local = new Uint8Array(30 + name.length);
|
||||
const view = new DataView(local.buffer);
|
||||
view.setUint32(0, 0x04034b50, true);
|
||||
view.setUint16(4, 20, true);
|
||||
view.setUint16(6, 0x800, true);
|
||||
view.setUint16(8, 0, true);
|
||||
view.setUint16(10, dosTime, true);
|
||||
view.setUint16(12, dosDate, true);
|
||||
view.setUint32(14, crc, true);
|
||||
view.setUint32(18, file.data.length, true);
|
||||
view.setUint32(22, file.data.length, true);
|
||||
view.setUint16(26, name.length, true);
|
||||
local.set(name, 30);
|
||||
locals.push(local, file.data);
|
||||
const cen = new Uint8Array(46 + name.length);
|
||||
const cenView = new DataView(cen.buffer);
|
||||
cenView.setUint32(0, 0x02014b50, true);
|
||||
cenView.setUint16(4, 20, true);
|
||||
cenView.setUint16(6, 20, true);
|
||||
cenView.setUint16(8, 0x800, true);
|
||||
cenView.setUint16(10, 0, true);
|
||||
cenView.setUint16(12, dosTime, true);
|
||||
cenView.setUint16(14, dosDate, true);
|
||||
cenView.setUint32(16, crc, true);
|
||||
cenView.setUint32(20, file.data.length, true);
|
||||
cenView.setUint32(24, file.data.length, true);
|
||||
cenView.setUint16(28, name.length, true);
|
||||
cenView.setUint32(42, offset, true);
|
||||
cen.set(name, 46);
|
||||
central.push(cen);
|
||||
offset += local.length + file.data.length;
|
||||
}
|
||||
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
|
||||
const end = new Uint8Array(22);
|
||||
const endView = new DataView(end.buffer);
|
||||
endView.setUint32(0, 0x06054b50, true);
|
||||
endView.setUint16(8, files.length, true);
|
||||
endView.setUint16(10, files.length, true);
|
||||
endView.setUint32(12, centralSize, true);
|
||||
endView.setUint32(16, offset, true);
|
||||
const parts = [...locals, ...central, end].map((part) => {
|
||||
const copy = new ArrayBuffer(part.byteLength);
|
||||
new Uint8Array(copy).set(part);
|
||||
return copy;
|
||||
});
|
||||
return new Blob(parts, { type: "application/zip" });
|
||||
}
|
||||
|
||||
/** Save every file in one zip. */
|
||||
export async function downloadAllAsZip(
|
||||
items: { url: string; name: string }[],
|
||||
archiveName: string,
|
||||
onProgress?: (ratio: number) => void,
|
||||
): Promise<void> {
|
||||
const used = new Map<string, number>();
|
||||
const files: { name: string; data: Uint8Array }[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
onProgress?.(i / Math.max(items.length, 1));
|
||||
const hit = await readCachedMedia(items[i].url);
|
||||
const blob = hit ?? (await fetchBlob(items[i].url));
|
||||
if (!hit) await writeCachedMedia(items[i].url, blob);
|
||||
files.push({ name: uniqueZipName(items[i].name, used), data: new Uint8Array(await blob.arrayBuffer()) });
|
||||
}
|
||||
onProgress?.(1);
|
||||
triggerDownload(buildStoredZip(files), archiveName || "files.zip");
|
||||
}
|
||||
|
||||
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
|
||||
export async function clearMediaCache(): Promise<void> {
|
||||
if (typeof caches === "undefined") return;
|
||||
await caches.delete(mediaCacheName);
|
||||
}
|
||||
|
||||
export async function loadCachedMedia(url: string): Promise<Blob> {
|
||||
const hit = await readCachedMedia(url);
|
||||
if (hit) return hit;
|
||||
|
||||
@@ -21,6 +21,13 @@ export interface User {
|
||||
securityQuestion?: string;
|
||||
hasSecurityAnswer?: boolean;
|
||||
createdAt: string;
|
||||
usernameChangesRemaining?: number;
|
||||
usernameCooldownUntil?: string;
|
||||
frozenAt?: string;
|
||||
closedAt?: string;
|
||||
closesAt?: string;
|
||||
isFrozen?: boolean;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
@@ -195,7 +202,9 @@ export interface ChatMessage {
|
||||
pinned?: boolean;
|
||||
editedAt?: string;
|
||||
attachment?: ChatAttachment;
|
||||
attachments?: ChatAttachment[];
|
||||
reactions?: ChatReaction[];
|
||||
buttons?: { text: string }[];
|
||||
}
|
||||
|
||||
export interface ChatReaction {
|
||||
@@ -210,6 +219,7 @@ export interface Mentor {
|
||||
email: string;
|
||||
role: Role;
|
||||
online?: boolean;
|
||||
lastSeen?: string;
|
||||
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
@@ -222,12 +232,18 @@ export interface Conversation {
|
||||
email: string;
|
||||
role: Role;
|
||||
online?: boolean;
|
||||
lastSeen?: string;
|
||||
saved?: boolean;
|
||||
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
};
|
||||
lastMessage?: ChatMessage;
|
||||
unreadCount: number;
|
||||
messageCount?: number;
|
||||
pinnedRank: number | null;
|
||||
muted: boolean;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
@@ -268,16 +284,23 @@ export interface MCQQuestion {
|
||||
export interface PublicProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
username?: string;
|
||||
role: Role;
|
||||
xp: number;
|
||||
hearts: number;
|
||||
streakCurrent: number;
|
||||
streakLongest: number;
|
||||
xp?: number;
|
||||
hearts?: number;
|
||||
streakCurrent?: number;
|
||||
streakLongest?: number;
|
||||
avatarVariant?: User["avatarVariant"];
|
||||
avatarPalette?: string;
|
||||
avatarPhoto?: string;
|
||||
isLocked: boolean;
|
||||
isLocked?: boolean;
|
||||
isFrozen?: boolean;
|
||||
isClosed?: boolean;
|
||||
private?: boolean;
|
||||
public?: boolean;
|
||||
banner?: string;
|
||||
createdAt?: string;
|
||||
socialLinks?: { name: string; url: string }[];
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
|
||||
@@ -22,6 +22,17 @@ export function formatStreakLabel(days: number) {
|
||||
/** Quiz option letters: الف، ب، ج، د، … */
|
||||
const FA_OPTION_LETTERS = ["الف", "ب", "ج", "د", "ه", "و"] as const;
|
||||
|
||||
/** Jalali day plus clock, for conversation rows. */
|
||||
export function formatJalaliStamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const day = new Intl.DateTimeFormat("fa-IR", { day: "numeric", month: "short" }).format(d);
|
||||
const time = new Intl.DateTimeFormat("fa-IR", { hour: "2-digit", minute: "2-digit" }).format(d);
|
||||
return `${day} · ${time}`;
|
||||
}
|
||||
|
||||
export function formatOptionLetter(index: number): string {
|
||||
if (index >= 0 && index < FA_OPTION_LETTERS.length) {
|
||||
return FA_OPTION_LETTERS[index];
|
||||
|
||||
Reference in New Issue
Block a user