feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.
What a user can now set, and where it shows:
- Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
- Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
hearts, frost, crown). Inline SVG — no image assets, scales to any size.
- Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
positions so re-renders do not change the weather.
- Nameplate behind the member-list row: 10 presets + custom two-colour.
- Theme: two colours washed behind the card, and the banner fallback.
- Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
also on the member row.
- Links, up to 5, with the brand icon picked from the host.
- Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
Games come from Steam search through a small server proxy (cached,
10 min) or are typed by hand; covers only load from Steam's CDN.
- Badges, derived rather than stored: instance admin, server owner,
founder (joined in the space's first month), veteran (a year), OG (six
months), stylist. Nothing to grant, revoke or migrate.
Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.
The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.
Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.
Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE `users` ADD `profile_style` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
||||
"when": 1788401450986,
|
||||
"tag": "0018_eminent_kid_colt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1788402724322,
|
||||
"tag": "0019_glamorous_crusher_hogan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export const users = sqliteTable('users', {
|
||||
profileUpdatedAt: integer('profile_updated_at'),
|
||||
passwordChangedAt: integer('password_changed_at'),
|
||||
showActivity: integer('show_activity').notNull().default(1),
|
||||
// JSON ProfileStyle (shared/profile.ts), normalised on write. One column,
|
||||
// not twelve: every field is optional, nothing is queried by value, and the
|
||||
// shape keeps growing. Null means defaults.
|
||||
profileStyle: text('profile_style'),
|
||||
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
||||
federationHealPending: integer('federation_heal_pending').default(0),
|
||||
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
||||
|
||||
@@ -31,6 +31,7 @@ import { searchRoutes } from './routes/search.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { gifRoutes } from './routes/gif.js';
|
||||
import { federationRoutes } from './routes/federation.js';
|
||||
import { gameRoutes } from './routes/games.js';
|
||||
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
||||
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
|
||||
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
||||
@@ -150,6 +151,7 @@ async function main(): Promise<void> {
|
||||
await app.register(searchRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(gifRoutes);
|
||||
await app.register(gameRoutes);
|
||||
await app.register(federationRoutes);
|
||||
await app.register(registerWebSocket);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { steamCoverUrl } from '@backspace/shared/src/profile.js';
|
||||
|
||||
/**
|
||||
* Game lookup for profile widgets, backed by Steam's public store search.
|
||||
*
|
||||
* Proxied rather than called from the browser: the store endpoint has no CORS
|
||||
* headers, and going through here means one place to cache, to bound the
|
||||
* query, and to swap the source later without touching clients. No key is
|
||||
* involved — it is the same unauthenticated endpoint the store website uses.
|
||||
*/
|
||||
interface SteamSearchItem { id: number; name: string; tiny_image?: string }
|
||||
interface GameHit { appId: number; name: string; cover: string; header: string }
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const cache = new Map<string, { at: number; hits: GameHit[] }>();
|
||||
|
||||
export async function gameRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Querystring: { q?: string } }>('/api/games/search', { preHandler: authenticate }, async (request, reply) => {
|
||||
const q = (request.query.q ?? '').trim().slice(0, 60);
|
||||
if (q.length < 2) return reply.send({ hits: [] });
|
||||
|
||||
const key = q.toLowerCase();
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return reply.send({ hits: cached.hits });
|
||||
|
||||
try {
|
||||
const url = `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(q)}&l=brazilian&cc=BR`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(6000), headers: { 'User-Agent': 'Resenhacord/1.0' } });
|
||||
if (!res.ok) return reply.send({ hits: [] });
|
||||
const data = (await res.json()) as { items?: SteamSearchItem[] };
|
||||
const hits: GameHit[] = (data.items ?? []).slice(0, 8).map((it) => ({
|
||||
appId: it.id,
|
||||
name: it.name,
|
||||
cover: steamCoverUrl(it.id),
|
||||
header: `https://cdn.cloudflare.steamstatic.com/steam/apps/${it.id}/header.jpg`,
|
||||
}));
|
||||
cache.set(key, { at: Date.now(), hits });
|
||||
if (cache.size > 500) cache.delete(cache.keys().next().value!);
|
||||
return reply.send({ hits });
|
||||
} catch {
|
||||
// Upstream hiccup is not the user's problem: an empty list lets them
|
||||
// type the title by hand instead.
|
||||
return reply.send({ hits: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { config } from '../config.js';
|
||||
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
|
||||
import { extractDomain } from './federation.js';
|
||||
import path from 'path';
|
||||
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
|
||||
|
||||
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
||||
export function isValidAssetUrl(url: string | null | undefined): boolean {
|
||||
@@ -187,7 +188,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity } = request.body;
|
||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity, profileStyle } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const updateData: Record<string, string | null | undefined> = {};
|
||||
@@ -426,12 +427,22 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
(updateData as Record<string, unknown>).showActivity = showActivity ? 1 : 0;
|
||||
}
|
||||
|
||||
// Profile style is normalised, never rejected: the shared normaliser
|
||||
// clamps lengths, drops bad URLs and falls back on unknown ids, so the
|
||||
// stored value is always well-formed. Null resets to defaults. Kept out of
|
||||
// DURABLE_PROFILE_FIELDS on purpose — peers on older builds do not know
|
||||
// the field, and it is cosmetic, so it stays local to this instance.
|
||||
if (profileStyle !== undefined) {
|
||||
(updateData as Record<string, unknown>).profileStyle =
|
||||
profileStyle === null ? null : JSON.stringify(normalizeProfileStyle(profileStyle));
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Detect profile changes and stamp timestamp
|
||||
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'].some(f => f in updateData);
|
||||
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus', 'profileStyle'].some(f => f in updateData);
|
||||
const hasDurableChange = DURABLE_PROFILE_FIELDS.some(f => f in updateData);
|
||||
if (hasDurableChange) {
|
||||
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { User, ReplicatedInstance } from '@backspace/shared';
|
||||
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
|
||||
import { schema } from '../db/index.js';
|
||||
|
||||
function safeParse(json: string): unknown {
|
||||
try { return JSON.parse(json); } catch { return null; }
|
||||
}
|
||||
|
||||
export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User {
|
||||
// Tombstoned (deleted) users — return anonymized profile
|
||||
if (row.isDeleted === 1) {
|
||||
@@ -54,6 +59,9 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal
|
||||
homeInstance: row.homeInstance ?? null,
|
||||
homeUserId: row.homeUserId ?? null,
|
||||
replicatedInstances,
|
||||
// Re-normalised on read as well: a row written by an older build (or a
|
||||
// stricter future one) still comes out complete and bounded.
|
||||
profileStyle: row.profileStyle ? normalizeProfileStyle(safeParse(row.profileStyle)) : null,
|
||||
...(isSelf
|
||||
? {
|
||||
showActivity: row.showActivity !== 0,
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"./src/audit": "./src/audit.ts",
|
||||
"./src/audit.js": "./src/audit.ts",
|
||||
"./src/mentions": "./src/mentions.ts",
|
||||
"./src/mentions.js": "./src/mentions.ts"
|
||||
"./src/mentions.js": "./src/mentions.ts",
|
||||
"./src/profile": "./src/profile.ts",
|
||||
"./src/profile.js": "./src/profile.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Profile identity: everything a user can style about how they appear, beyond
|
||||
* the avatar/banner/bio fields that predate it.
|
||||
*
|
||||
* Stored as one JSON column (`users.profile_style`) rather than a dozen
|
||||
* columns: the shape will keep growing (new effects, new widgets), every field
|
||||
* is optional, and nothing here is ever queried by value. The server
|
||||
* normalises on write with `normalizeProfileStyle`, so what reaches clients is
|
||||
* always a complete, bounded, well-formed object.
|
||||
*
|
||||
* Catalog ids are strings on purpose — a client that does not know an id
|
||||
* (older build, other instance) falls back to `none`/`default` instead of
|
||||
* crashing on an enum it has never seen.
|
||||
*/
|
||||
|
||||
export const NAME_FONTS = [
|
||||
'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker',
|
||||
] as const;
|
||||
export type NameFont = (typeof NAME_FONTS)[number];
|
||||
|
||||
export const NAME_EFFECTS = [
|
||||
'solid', 'gradient', 'neon', 'toon', 'pop', 'gummy', 'prism', 'glitch', 'fire',
|
||||
] as const;
|
||||
export type NameEffect = (typeof NAME_EFFECTS)[number];
|
||||
|
||||
export const AVATAR_DECORATIONS = [
|
||||
'none', 'ring', 'neon', 'flames', 'stars', 'halo', 'vines', 'pixel', 'hearts', 'frost', 'crown',
|
||||
] as const;
|
||||
export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number];
|
||||
|
||||
export const PROFILE_EFFECTS = [
|
||||
'none', 'sparkles', 'rain', 'snow', 'matrix', 'confetti', 'aurora', 'embers', 'bubbles', 'hearts',
|
||||
] as const;
|
||||
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
|
||||
|
||||
export const NAMEPLATES = [
|
||||
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight', 'custom',
|
||||
] as const;
|
||||
export type Nameplate = (typeof NAMEPLATES)[number];
|
||||
|
||||
export interface NameStyle {
|
||||
font: NameFont;
|
||||
effect: NameEffect;
|
||||
/** Two hex colours. Solid uses the first; every other effect uses both. */
|
||||
colors: [string, string];
|
||||
}
|
||||
|
||||
export interface ProfileTheme {
|
||||
/** Two hex colours behind the card and the banner fallback. */
|
||||
colors: [string, string];
|
||||
}
|
||||
|
||||
export interface ProfileTag {
|
||||
/** 2–4 characters, shown uppercase in a chip next to the name. */
|
||||
text: string;
|
||||
/** Single emoji (grapheme) in front of the text; empty for none. */
|
||||
emoji: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProfileLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GameEntry {
|
||||
/** Steam app id when picked from search; null for a free-typed title. */
|
||||
appId: number | null;
|
||||
name: string;
|
||||
/** https URL of a cover image (Steam CDN when picked from search). */
|
||||
cover: string | null;
|
||||
note: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ProfileQuote {
|
||||
text: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface ProfileWidgets {
|
||||
favoriteGame: GameEntry | null;
|
||||
rotation: GameEntry[];
|
||||
wishlist: GameEntry[];
|
||||
quote: ProfileQuote | null;
|
||||
}
|
||||
|
||||
export interface ProfileStyle {
|
||||
name: NameStyle;
|
||||
theme: ProfileTheme | null;
|
||||
decoration: AvatarDecoration;
|
||||
effect: ProfileEffect;
|
||||
nameplate: Nameplate;
|
||||
/** Only read when nameplate === 'custom'. */
|
||||
nameplateColors: [string, string];
|
||||
pronouns: string;
|
||||
tag: ProfileTag | null;
|
||||
links: ProfileLink[];
|
||||
widgets: ProfileWidgets;
|
||||
}
|
||||
|
||||
export const PROFILE_LIMITS = {
|
||||
pronouns: 40,
|
||||
tagMin: 2,
|
||||
tagMax: 4,
|
||||
links: 5,
|
||||
linkLabel: 24,
|
||||
linkUrl: 200,
|
||||
gameName: 80,
|
||||
gameNote: 80,
|
||||
gameTags: 3,
|
||||
gameTag: 16,
|
||||
rotation: 5,
|
||||
wishlist: 10,
|
||||
quoteText: 140,
|
||||
quoteAuthor: 40,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
|
||||
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
|
||||
theme: null,
|
||||
decoration: 'none',
|
||||
effect: 'none',
|
||||
nameplate: 'none',
|
||||
nameplateColors: ['#7c6cf6', '#f472b6'],
|
||||
pronouns: '',
|
||||
tag: null,
|
||||
links: [],
|
||||
widgets: { favoriteGame: null, rotation: [], wishlist: [], quote: null },
|
||||
};
|
||||
|
||||
// ─── Normalisation ────────────────────────────────────────────────────────────
|
||||
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
/** Domains whose images a profile may embed. Anything else is dropped, not
|
||||
* proxied — a profile is not a place to load arbitrary third-party pixels. */
|
||||
const COVER_HOSTS = ['cdn.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net'];
|
||||
|
||||
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
|
||||
}
|
||||
function hex(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
|
||||
}
|
||||
function str(value: unknown, max: number): string {
|
||||
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
}
|
||||
function httpsUrl(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const v = value.trim();
|
||||
if (v.length === 0 || v.length > max) return null;
|
||||
try {
|
||||
const u = new URL(v);
|
||||
return u.protocol === 'https:' ? u.toString() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function coverUrl(value: unknown): string | null {
|
||||
const u = httpsUrl(value, 300);
|
||||
if (!u) return null;
|
||||
try { return COVER_HOSTS.includes(new URL(u).hostname) ? u : null; } catch { return null; }
|
||||
}
|
||||
/** First grapheme only — one emoji, not a sentence hidden in the tag. */
|
||||
function oneGrapheme(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const s = value.trim();
|
||||
if (!s) return '';
|
||||
const seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
|
||||
if (seg) {
|
||||
for (const g of new seg('und', { granularity: 'grapheme' }).segment(s)) return g.segment;
|
||||
}
|
||||
return Array.from(s)[0] ?? '';
|
||||
}
|
||||
function game(value: unknown): GameEntry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
const name = str(v.name, PROFILE_LIMITS.gameName);
|
||||
if (!name) return null;
|
||||
const appId = typeof v.appId === 'number' && Number.isInteger(v.appId) && v.appId > 0 ? v.appId : null;
|
||||
const tags = Array.isArray(v.tags)
|
||||
? v.tags.map((t) => str(t, PROFILE_LIMITS.gameTag)).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags)
|
||||
: [];
|
||||
return { appId, name, cover: coverUrl(v.cover), note: str(v.note, PROFILE_LIMITS.gameNote), tags };
|
||||
}
|
||||
function games(value: unknown, max: number): GameEntry[] {
|
||||
return Array.isArray(value) ? value.map(game).filter((g): g is GameEntry => g !== null).slice(0, max) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces any input into a valid ProfileStyle. Never throws: unknown ids fall
|
||||
* back to defaults, over-long strings are cut, bad colours are replaced, bad
|
||||
* URLs are dropped. The server stores exactly this output.
|
||||
*/
|
||||
export function normalizeProfileStyle(input: unknown): ProfileStyle {
|
||||
const d = DEFAULT_PROFILE_STYLE;
|
||||
const v = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
|
||||
const name = (v.name && typeof v.name === 'object' ? v.name : {}) as Record<string, unknown>;
|
||||
const nameColors = Array.isArray(name.colors) ? name.colors : [];
|
||||
const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null;
|
||||
const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
|
||||
const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : [];
|
||||
const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null;
|
||||
const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : '';
|
||||
const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>;
|
||||
const quote = (widgets.quote && typeof widgets.quote === 'object' ? widgets.quote : null) as Record<string, unknown> | null;
|
||||
const quoteText = quote ? str(quote.text, PROFILE_LIMITS.quoteText) : '';
|
||||
|
||||
const links: ProfileLink[] = Array.isArray(v.links)
|
||||
? v.links.map((l) => {
|
||||
const o = (l && typeof l === 'object' ? l : {}) as Record<string, unknown>;
|
||||
const url = httpsUrl(o.url, PROFILE_LIMITS.linkUrl);
|
||||
if (!url) return null;
|
||||
const label = str(o.label, PROFILE_LIMITS.linkLabel) || new URL(url).hostname.replace(/^www\./, '');
|
||||
return { label, url };
|
||||
}).filter((l): l is ProfileLink => l !== null).slice(0, PROFILE_LIMITS.links)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: {
|
||||
font: pick(name.font, NAME_FONTS, d.name.font),
|
||||
effect: pick(name.effect, NAME_EFFECTS, d.name.effect),
|
||||
colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])],
|
||||
},
|
||||
theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null,
|
||||
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
|
||||
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
|
||||
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),
|
||||
nameplateColors: [hex(npColors[0], d.nameplateColors[0]), hex(npColors[1], d.nameplateColors[1])],
|
||||
pronouns: str(v.pronouns, PROFILE_LIMITS.pronouns),
|
||||
tag: tagText.length >= PROFILE_LIMITS.tagMin
|
||||
? { text: tagText, emoji: oneGrapheme(tag?.emoji), color: hex(tag?.color, '#7c6cf6') }
|
||||
: null,
|
||||
links,
|
||||
widgets: {
|
||||
favoriteGame: game(widgets.favoriteGame),
|
||||
rotation: games(widgets.rotation, PROFILE_LIMITS.rotation),
|
||||
wishlist: games(widgets.wishlist, PROFILE_LIMITS.wishlist),
|
||||
quote: quoteText ? { text: quoteText, author: str(quote?.author, PROFILE_LIMITS.quoteAuthor) } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a style differs from the defaults — used to skip rendering work. */
|
||||
export function isDefaultProfileStyle(s: ProfileStyle | null | undefined): boolean {
|
||||
return !s || JSON.stringify(s) === JSON.stringify(DEFAULT_PROFILE_STYLE);
|
||||
}
|
||||
|
||||
/** Steam's poster-format cover for an app id. */
|
||||
export function steamCoverUrl(appId: number): string {
|
||||
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/library_600x900.jpg`;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface User {
|
||||
showActivity?: boolean;
|
||||
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
|
||||
federationHomeOrphaned?: boolean;
|
||||
/** Name style, decorations, effects, tag, links, widgets. See shared/profile.ts. Null = defaults. */
|
||||
profileStyle?: import('./profile').ProfileStyle | null;
|
||||
}
|
||||
|
||||
export interface ReplicatedInstance {
|
||||
@@ -591,6 +593,8 @@ export interface UpdateUserRequest {
|
||||
profileUpdatedAt?: number;
|
||||
discoverable?: boolean;
|
||||
showActivity?: boolean;
|
||||
/** Normalised server-side; pass null to reset to defaults. */
|
||||
profileStyle?: import('./profile').ProfileStyle | null;
|
||||
}
|
||||
|
||||
export interface UpdateMemberRequest {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -91,3 +91,9 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
---
|
||||
Display fonts for profile name styles (latin subsets, Google Fonts builds), all
|
||||
licensed under the SIL Open Font License 1.1 above:
|
||||
Bangers, Cinzel, Fredoka, Orbitron, Pacifico, Permanent Marker, Playfair Display,
|
||||
Press Start 2P, Rubik Mono One, Space Mono.
|
||||
|
||||
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.
@@ -341,6 +341,10 @@ export class BackspaceApiClient {
|
||||
removeSticker: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
readonly games: {
|
||||
search: (q: string) => Promise<{ hits: { appId: number; name: string; cover: string; header: string }[] }>;
|
||||
};
|
||||
|
||||
readonly pins: {
|
||||
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
|
||||
pin: (messageId: string) => Promise<{ success: boolean }>;
|
||||
@@ -786,6 +790,10 @@ export class BackspaceApiClient {
|
||||
removeSticker: (id) => request<void>('DELETE', `/stickers/${id}`),
|
||||
};
|
||||
|
||||
this.games = {
|
||||
search: (q: string) => request<{ hits: { appId: number; name: string; cover: string; header: string }[] }>('GET', `/games/search?q=${encodeURIComponent(q)}`),
|
||||
};
|
||||
|
||||
this.pins = {
|
||||
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
|
||||
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||
import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
|
||||
import { getProfileStyle, nameplateProps } from '../../utils/profileStyle';
|
||||
import { ProfileTagChip } from '../profile/ProfileTagChip';
|
||||
|
||||
/**
|
||||
* Derives the display group for a member based on their highest-positioned role
|
||||
@@ -71,15 +73,19 @@ function MemberSidebarRow({
|
||||
const { baseName } = parseFederatedUsername(canonical.username);
|
||||
const displayName = canonical.displayName ?? baseName;
|
||||
|
||||
const style = getProfileStyle(canonical);
|
||||
const plate = isOffline ? null : nameplateProps(style.nameplate, style.nameplateColors);
|
||||
|
||||
const rowClass = isRichActivity
|
||||
? `flex items-center gap-2.5 px-2.5 py-2 rounded-[10px] mb-1 cursor-pointer transition-colors glass-pill border-l-2 ${accentClass}`
|
||||
: 'flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] hover:bg-interactive-hover cursor-pointer group transition-colors';
|
||||
: `flex items-center gap-2.5 px-2 py-1.5 rounded-[4px] cursor-pointer group transition-colors ${plate ? plate.className : 'hover:bg-interactive-hover'}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.userId}
|
||||
onClick={(e) => onClickMember(e, canonical)}
|
||||
className={rowClass}
|
||||
style={plate?.style}
|
||||
>
|
||||
<Avatar
|
||||
src={canonical.avatar}
|
||||
@@ -90,11 +96,14 @@ function MemberSidebarRow({
|
||||
user={canonical}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Username
|
||||
username={displayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
||||
style={colorStyle}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Username
|
||||
username={displayName}
|
||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
||||
style={colorStyle}
|
||||
/>
|
||||
{!isOffline && style.tag && <ProfileTagChip tag={style.tag} className="scale-90 origin-left" />}
|
||||
</div>
|
||||
{!isOffline && isFederationGlobeApplicable(canonical) && (
|
||||
<div className="text-[10px] leading-[1.3] text-txt-tertiary truncate opacity-60">@{parseFederatedUsername(canonical.username).domain}</div>
|
||||
)}
|
||||
|
||||
@@ -16,8 +16,16 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { getAvatarGradient, getSpaceGradient, adjustColor, mutedGradient } from '../../utils/gradients';
|
||||
import { parseFederatedUsername, isSelf, canonicalUserMatch } from '../../utils/identity';
|
||||
import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals';
|
||||
import { StyledName } from '../profile/StyledName';
|
||||
import { DecoratedAvatar } from '../profile/DecoratedAvatar';
|
||||
import { ProfileEffectLayer } from '../profile/ProfileEffectLayer';
|
||||
import { ProfileTagChip } from '../profile/ProfileTagChip';
|
||||
import { ProfileBadges } from '../profile/ProfileBadges';
|
||||
import { ProfileLinks } from '../profile/ProfileLinks';
|
||||
import { ProfileBoard } from '../profile/ProfileWidgets';
|
||||
import { getProfileStyle, computeBadges, themeGradient } from '../../utils/profileStyle';
|
||||
|
||||
type Tab = 'about' | 'friends' | 'spaces';
|
||||
type Tab = 'about' | 'board' | 'friends' | 'spaces';
|
||||
|
||||
type FriendshipStatus =
|
||||
| { state: 'self' }
|
||||
@@ -68,6 +76,9 @@ export function UserProfileModal() {
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const t = useT();
|
||||
const allSpaces = useSpaceStore((st) => st.spaces);
|
||||
const currentSpaceIdForBadges = useSpaceStore((st) => st.currentSpaceId);
|
||||
const spaceMembers = useSpaceStore((st) => st.members);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [userOrigin, setUserOrigin] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('about');
|
||||
@@ -164,6 +175,9 @@ export function UserProfileModal() {
|
||||
|
||||
const { baseName, domain } = parseFederatedUsername(user.username);
|
||||
const displayName = user.displayName ?? baseName;
|
||||
const style = getProfileStyle(user);
|
||||
const profileSpace = allSpaces.find((sp) => sp.id === currentSpaceIdForBadges) ?? null;
|
||||
const profileMember = spaceMembers.find((m) => m.userId === user.id) ?? null;
|
||||
|
||||
// Banner — use correct API client for remote users
|
||||
const profileApi = getApiForOrigin(userOrigin);
|
||||
@@ -265,6 +279,7 @@ export function UserProfileModal() {
|
||||
|
||||
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||
{ key: 'about', label: 'About' },
|
||||
{ key: 'board', label: t('profile.tab.board') },
|
||||
{ key: 'friends', label: 'Mutual Friends', count: mutualFriends.length },
|
||||
{ key: 'spaces', label: 'Mutual Spaces', count: mutualSpaces.length },
|
||||
];
|
||||
@@ -278,9 +293,10 @@ export function UserProfileModal() {
|
||||
className="h-[100px] flex-shrink-0 relative"
|
||||
style={bannerSrc
|
||||
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: { background: bannerFallback }
|
||||
: { background: themeGradient(style) ?? bannerFallback }
|
||||
}
|
||||
>
|
||||
<ProfileEffectLayer effect={style.effect} density={1.6} />
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={closeModal}
|
||||
@@ -294,24 +310,29 @@ export function UserProfileModal() {
|
||||
|
||||
{/* Header (avatar + name) */}
|
||||
<div className="px-5 flex-shrink-0 relative">
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
name={displayName}
|
||||
size={96}
|
||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||
userId={user.homeUserId ?? user.id}
|
||||
user={user}
|
||||
ring={{ width: 4, color: 'rgba(20,20,26,0.82)' }}
|
||||
className="mt-[-52px] mb-2"
|
||||
/>
|
||||
<div className="flex items-end justify-between mt-[-52px] mb-2">
|
||||
<DecoratedAvatar decoration={style.decoration}>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
name={displayName}
|
||||
size={96}
|
||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||
userId={user.homeUserId ?? user.id}
|
||||
user={user}
|
||||
ring={{ width: 4, color: 'rgba(20,20,26,0.82)' }}
|
||||
/>
|
||||
</DecoratedAvatar>
|
||||
<div className="mb-1"><ProfileBadges badges={computeBadges(user, { space: profileSpace, member: profileMember })} size={20} /></div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<Username
|
||||
username={displayName}
|
||||
className="text-[20px] font-bold leading-tight"
|
||||
/>
|
||||
<div className="text-[14px] text-txt-tertiary mt-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StyledName name={displayName} style={style.name} className="text-[22px] font-bold leading-tight" />
|
||||
{style.tag && <ProfileTagChip tag={style.tag} />}
|
||||
</div>
|
||||
<div className="text-[14px] text-txt-tertiary mt-0.5 flex items-center gap-1.5 flex-wrap">
|
||||
<Username username={user.username} showAt className="text-[14px] text-txt-tertiary" />
|
||||
{style.pronouns && <><span className="opacity-50">•</span><span>{style.pronouns}</span></>}
|
||||
</div>
|
||||
{user.customStatus && (
|
||||
<div className="text-[13px] text-txt-secondary italic mt-1">
|
||||
@@ -374,6 +395,12 @@ export function UserProfileModal() {
|
||||
|
||||
{/* Current activity — the "Listening to Spotify" block */}
|
||||
<ProfileActivity activities={activities} />
|
||||
{style.links.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary mb-1.5">{t('profile.links')}</div>
|
||||
<ProfileLinks links={style.links} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Since */}
|
||||
<div>
|
||||
@@ -392,6 +419,8 @@ export function UserProfileModal() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'board' && <ProfileBoard widgets={style.widgets} />}
|
||||
|
||||
{activeTab === 'friends' && (
|
||||
<div>
|
||||
{loadingMutuals ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { InstanceInfoResponse } from '@backspace/shared';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { AccountPanel } from './settingsPanels/AccountPanel';
|
||||
import { ProfilePanel } from './settingsPanels/ProfilePanel';
|
||||
import { VoicePanel } from './settingsPanels/VoicePanel';
|
||||
import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
|
||||
import { LanguagePanel } from './settingsPanels/LanguagePanel';
|
||||
@@ -18,7 +19,7 @@ import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext';
|
||||
|
||||
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'language' | 'desktop' | 'instance';
|
||||
type SettingsTab = 'account' | 'profile' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'language' | 'desktop' | 'instance';
|
||||
|
||||
function SidebarSubLinks() {
|
||||
const ctx = useSettingsSectionsContext();
|
||||
@@ -139,6 +140,7 @@ export function UserSettingsModal() {
|
||||
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.userSettings')}</div>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||
<button onClick={() => handleTabClick('profile')} className={tabClass('profile')}>{t('settings.tab.profile')}</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
|
||||
|
||||
@@ -197,6 +199,7 @@ export function UserSettingsModal() {
|
||||
<div className="glass-bubble rounded-lg p-2 space-y-0.5">
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||
<button onClick={() => handleTabClick('profile')} className={tabClass('profile')}>{t('settings.tab.profile')}</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
|
||||
|
||||
@@ -250,6 +253,7 @@ export function UserSettingsModal() {
|
||||
</button>
|
||||
)}
|
||||
{tab === 'account' && <AccountPanel />}
|
||||
{tab === 'profile' && <ProfilePanel />}
|
||||
{tab === 'voice' && <VoicePanel />}
|
||||
{tab === 'privacy' && <PrivacyPanel />}
|
||||
{tab === 'connections' && <ConnectionsPanel />}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../../../stores/authStore';
|
||||
import { useUIStore } from '../../../stores/uiStore';
|
||||
import { api } from '../../../api/client';
|
||||
import { useT, type TranslationKey } from '../../../i18n';
|
||||
import { parseFederatedUsername } from '../../../utils/identity';
|
||||
import { getProfileStyle, nameStyleProps, nameplateProps } from '../../../utils/profileStyle';
|
||||
import { ProfileCard } from '../../profile/ProfileCard';
|
||||
import { DecoratedAvatar } from '../../profile/DecoratedAvatar';
|
||||
import { ProfileEffectLayer } from '../../profile/ProfileEffectLayer';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import {
|
||||
NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES, PROFILE_LIMITS,
|
||||
DEFAULT_PROFILE_STYLE, normalizeProfileStyle, steamCoverUrl,
|
||||
type ProfileStyle, type GameEntry,
|
||||
} from '@backspace/shared/src/profile';
|
||||
|
||||
/** Colour pairs offered as one-click presets for name and theme. */
|
||||
const PALETTES: [string, string][] = [
|
||||
['#efefef', '#7c6cf6'], ['#f97316', '#fde047'], ['#22d3ee', '#a855f7'], ['#4ade80', '#22d3ee'],
|
||||
['#f472b6', '#fbbf24'], ['#fb7185', '#7c3aed'], ['#a3e635', '#16a34a'], ['#ffffff', '#94a3b8'],
|
||||
];
|
||||
|
||||
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{title}</div>
|
||||
{hint && <p className="text-xs text-txt-tertiary mb-2">{hint}</p>}
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPair({ value, onChange }: { value: [string, string]; onChange: (v: [string, string]) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{PALETTES.map((p) => (
|
||||
<button key={p.join()} type="button" onClick={() => onChange(p)}
|
||||
className={`w-7 h-7 rounded-full border-2 transition-transform hover:scale-110 ${p[0] === value[0] && p[1] === value[1] ? 'border-white' : 'border-transparent'}`}
|
||||
style={{ background: `linear-gradient(135deg, ${p[0]}, ${p[1]})` }} />
|
||||
))}
|
||||
<span className="w-px h-6 bg-white/10 mx-1" />
|
||||
{[0, 1].map((i) => (
|
||||
<label key={i} className="w-7 h-7 rounded-full overflow-hidden border-2 border-white/20 cursor-pointer" style={{ background: value[i] }}>
|
||||
<input type="color" value={value[i]} onChange={(e) => { const n: [string, string] = [value[0], value[1]]; n[i] = e.target.value; onChange(n); }} className="opacity-0 w-full h-full cursor-pointer" />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameSearch({ onPick, placeholder }: { onPick: (g: GameEntry) => void; placeholder: string }) {
|
||||
const t = useT();
|
||||
const [q, setQ] = useState('');
|
||||
const [hits, setHits] = useState<{ appId: number; name: string; cover: string }[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
if (q.trim().length < 2) { setHits([]); return; }
|
||||
timer.current = setTimeout(async () => {
|
||||
setBusy(true);
|
||||
try { setHits((await api.games.search(q.trim())).hits); } catch { setHits([]); } finally { setBusy(false); }
|
||||
}, 350);
|
||||
return () => { if (timer.current) clearTimeout(timer.current); };
|
||||
}, [q]);
|
||||
const pick = (g: GameEntry) => { onPick(g); setQ(''); setHits([]); };
|
||||
return (
|
||||
<div className="relative">
|
||||
<input type="text" value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="input-standard w-full text-sm"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && q.trim()) { e.preventDefault(); pick({ appId: null, name: q.trim(), cover: null, note: '', tags: [] }); } }} />
|
||||
{(hits.length > 0 || busy) && (
|
||||
<div className="absolute z-20 left-0 right-0 mt-1 rounded-lg glass-modal overflow-hidden max-h-64 overflow-y-auto scrollbar-thin">
|
||||
{busy && hits.length === 0 && <div className="px-3 py-2 text-xs text-txt-tertiary">{t('profile.editor.searching')}</div>}
|
||||
{hits.map((h) => (
|
||||
<button key={h.appId} type="button" onClick={() => pick({ appId: h.appId, name: h.name, cover: steamCoverUrl(h.appId), note: '', tags: [] })}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-interactive-hover text-left">
|
||||
<img src={h.cover} alt="" className="w-6 h-9 rounded object-cover bg-black/40" loading="lazy" />
|
||||
<span className="text-sm text-txt-primary truncate">{h.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{q.trim() && !busy && (
|
||||
<button type="button" onClick={() => pick({ appId: null, name: q.trim(), cover: null, note: '', tags: [] })} className="w-full px-3 py-1.5 text-xs text-txt-tertiary hover:bg-interactive-hover text-left">
|
||||
{t('profile.editor.useTyped', { name: q.trim() })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameEditor({ game, onChange, onRemove, withNote }: { game: GameEntry; onChange: (g: GameEntry) => void; onRemove: () => void; withNote?: boolean }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 rounded-lg bg-white/[0.04] p-2">
|
||||
{game.cover ? <img src={game.cover} alt="" className="w-10 h-[60px] rounded object-cover bg-black/40" /> : <div className="w-10 h-[60px] rounded bg-white/[0.06]" />}
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="text-sm font-medium text-txt-primary truncate">{game.name}</div>
|
||||
{withNote && (
|
||||
<input type="text" value={game.note} maxLength={PROFILE_LIMITS.gameNote} onChange={(e) => onChange({ ...game, note: e.target.value })}
|
||||
placeholder={t('profile.editor.gameNote')} className="input-standard w-full text-xs py-1" />
|
||||
)}
|
||||
<input type="text" value={game.tags.join(', ')} onChange={(e) => onChange({ ...game, tags: e.target.value.split(',').map((x) => x.trim()).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags) })}
|
||||
placeholder={t('profile.editor.gameTags')} className="input-standard w-full text-xs py-1" />
|
||||
</div>
|
||||
<button type="button" onClick={onRemove} className="text-txt-tertiary hover:text-accent-rose text-lg leading-none px-1" aria-label={t('profile.editor.remove')}>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilePanel() {
|
||||
const t = useT();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
const addToast = useUIStore((s) => s.addToast);
|
||||
|
||||
const saved = useMemo(() => getProfileStyle(user), [user]);
|
||||
const [draft, setDraft] = useState<ProfileStyle>(saved);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => { setDraft(saved); }, [saved]);
|
||||
|
||||
if (!user) return null;
|
||||
const displayName = user.displayName ?? parseFederatedUsername(user.username).baseName;
|
||||
const bannerSrc = user.banner ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : api.uploads.url(user.banner)) : null;
|
||||
const preview: typeof user = { ...user, profileStyle: draft };
|
||||
const isDirty = JSON.stringify(normalizeProfileStyle(draft)) !== JSON.stringify(saved);
|
||||
|
||||
const set = <K extends keyof ProfileStyle>(k: K, v: ProfileStyle[K]) => setDraft((d) => ({ ...d, [k]: v }));
|
||||
const setW = <K extends keyof ProfileStyle['widgets']>(k: K, v: ProfileStyle['widgets'][K]) => setDraft((d) => ({ ...d, widgets: { ...d.widgets, [k]: v } }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await updateProfile({ profileStyle: normalizeProfileStyle(draft) });
|
||||
addToast(t('profile.editor.saved'), 'success', 2000);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : t('profile.editor.saveFailed')); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
const handleReset = () => setDraft(DEFAULT_PROFILE_STYLE);
|
||||
|
||||
const chip = (active: boolean) => `px-2.5 py-1.5 rounded-md text-sm border transition-colors ${active ? 'border-accent-primary bg-accent-primary/15 text-txt-primary' : 'border-white/[0.06] bg-white/[0.03] text-txt-secondary hover:bg-white/[0.06]'}`;
|
||||
const tile = (active: boolean) => `relative rounded-lg border-2 p-2 flex flex-col items-center gap-1 transition-colors ${active ? 'border-accent-primary bg-accent-primary/10' : 'border-white/[0.06] bg-white/[0.03] hover:bg-white/[0.06]'}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-txt-primary mb-2">{t('profile.editor.title')}</h2>
|
||||
<p className="text-xs text-txt-tertiary -mt-3">{t('profile.editor.subtitle')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_360px] gap-5 items-start">
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Name style */}
|
||||
<Section title={t('profile.editor.name')} hint={t('profile.editor.name.hint')}>
|
||||
<div className="text-xs text-txt-tertiary mb-1.5">{t('profile.editor.font')}</div>
|
||||
<div className="grid grid-cols-5 gap-1.5 mb-3">
|
||||
{NAME_FONTS.map((f) => (
|
||||
<button key={f} type="button" onClick={() => set('name', { ...draft.name, font: f })} className={tile(draft.name.font === f)} title={t(`profile.font.${f}` as TranslationKey)}>
|
||||
<span className={`name-font-${f} text-[20px] leading-none text-txt-primary`}>Gg</span>
|
||||
<span className="text-[10px] text-txt-tertiary truncate w-full text-center">{t(`profile.font.${f}` as TranslationKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-txt-tertiary mb-1.5">{t('profile.editor.effect')}</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{NAME_EFFECTS.map((e) => {
|
||||
const p = nameStyleProps({ font: 'default', effect: e, colors: draft.name.colors });
|
||||
return (
|
||||
<button key={e} type="button" onClick={() => set('name', { ...draft.name, effect: e })} className={chip(draft.name.effect === e)}>
|
||||
<span className={p.className} style={p.style}>{t(`profile.effect.${e}` as TranslationKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-txt-tertiary mb-1.5">{t('profile.editor.colors')}</div>
|
||||
<ColorPair value={draft.name.colors} onChange={(c) => set('name', { ...draft.name, colors: c })} />
|
||||
</Section>
|
||||
|
||||
{/* Decoration */}
|
||||
<Section title={t('profile.editor.decoration')}>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{AVATAR_DECORATIONS.map((d) => (
|
||||
<button key={d} type="button" onClick={() => set('decoration', d)} className={tile(draft.decoration === d)} title={t(`profile.decoration.${d}` as TranslationKey)}>
|
||||
<DecoratedAvatar decoration={d}>
|
||||
<Avatar src={user.avatar} name={displayName} size={40} userId={user.homeUserId ?? user.id} user={user} />
|
||||
</DecoratedAvatar>
|
||||
<span className="text-[10px] text-txt-tertiary truncate w-full text-center">{t(`profile.decoration.${d}` as TranslationKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Profile effect */}
|
||||
<Section title={t('profile.editor.profileEffect')}>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{PROFILE_EFFECTS.map((e) => (
|
||||
<button key={e} type="button" onClick={() => set('effect', e)} className={tile(draft.effect === e)}>
|
||||
<div className="relative w-full h-12 rounded-md overflow-hidden bg-gradient-to-br from-[#1e1b4b] to-[#0b0b10]">
|
||||
<ProfileEffectLayer effect={e} density={0.6} />
|
||||
</div>
|
||||
<span className="text-[10px] text-txt-tertiary">{t(`profile.profileEffect.${e}` as TranslationKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Nameplate */}
|
||||
<Section title={t('profile.editor.nameplate')} hint={t('profile.editor.nameplate.hint')}>
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
{NAMEPLATES.map((n) => {
|
||||
const p = nameplateProps(n, draft.nameplateColors);
|
||||
return (
|
||||
<button key={n} type="button" onClick={() => set('nameplate', n)} className={tile(draft.nameplate === n)}>
|
||||
<div className={`w-full h-8 rounded-md flex items-center gap-2 px-2 ${p?.className ?? 'bg-white/[0.04]'}`} style={p?.style}>
|
||||
<Avatar src={user.avatar} name={displayName} size={18} userId={user.homeUserId ?? user.id} user={user} />
|
||||
<span className="text-[11px] text-txt-primary truncate">{displayName}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-txt-tertiary">{t(`profile.nameplate.${n}` as TranslationKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{draft.nameplate === 'custom' && <ColorPair value={draft.nameplateColors} onChange={(c) => set('nameplateColors', c)} />}
|
||||
</Section>
|
||||
|
||||
{/* Theme */}
|
||||
<Section title={t('profile.editor.theme')} hint={t('profile.editor.theme.hint')}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" onClick={() => set('theme', null)} className={chip(draft.theme === null)}>{t('profile.editor.theme.none')}</button>
|
||||
<ColorPair value={draft.theme?.colors ?? ['#7c6cf6', '#0b0b10']} onChange={(c) => set('theme', { colors: c })} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Identity: pronouns + tag */}
|
||||
<Section title={t('profile.editor.identity')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="block text-xs text-txt-secondary mb-1.5">{t('profile.editor.pronouns')}</span>
|
||||
<input type="text" value={draft.pronouns} maxLength={PROFILE_LIMITS.pronouns} onChange={(e) => set('pronouns', e.target.value)} placeholder={t('profile.editor.pronouns.placeholder')} className="input-standard w-full" />
|
||||
</label>
|
||||
<div>
|
||||
<span className="block text-xs text-txt-secondary mb-1.5">{t('profile.editor.tag')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" value={draft.tag?.emoji ?? ''} onChange={(e) => set('tag', { text: draft.tag?.text ?? '', emoji: e.target.value, color: draft.tag?.color ?? '#7c6cf6' })} placeholder="🔥" className="input-standard w-12 text-center" />
|
||||
<input type="text" value={draft.tag?.text ?? ''} maxLength={PROFILE_LIMITS.tagMax} onChange={(e) => set('tag', { text: e.target.value.toUpperCase(), emoji: draft.tag?.emoji ?? '', color: draft.tag?.color ?? '#7c6cf6' })} placeholder="STRT" className="input-standard w-24 uppercase tracking-wider" />
|
||||
<label className="w-8 h-8 rounded-md overflow-hidden border border-white/20 cursor-pointer flex-shrink-0" style={{ background: draft.tag?.color ?? '#7c6cf6' }}>
|
||||
<input type="color" value={draft.tag?.color ?? '#7c6cf6'} onChange={(e) => set('tag', { text: draft.tag?.text ?? '', emoji: draft.tag?.emoji ?? '', color: e.target.value })} className="opacity-0 w-full h-full cursor-pointer" />
|
||||
</label>
|
||||
{draft.tag && <button type="button" onClick={() => set('tag', null)} className="text-xs text-txt-tertiary hover:text-txt-secondary">{t('profile.editor.remove')}</button>}
|
||||
</div>
|
||||
<p className="text-[11px] text-txt-tertiary mt-1">{t('profile.editor.tag.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Links */}
|
||||
<Section title={t('profile.editor.links')} hint={t('profile.editor.links.hint')}>
|
||||
<div className="space-y-2">
|
||||
{draft.links.map((l, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input type="text" value={l.label} maxLength={PROFILE_LIMITS.linkLabel} onChange={(e) => set('links', draft.links.map((x, j) => j === i ? { ...x, label: e.target.value } : x))} placeholder={t('profile.editor.linkLabel')} className="input-standard w-36 text-sm" />
|
||||
<input type="url" value={l.url} maxLength={PROFILE_LIMITS.linkUrl} onChange={(e) => set('links', draft.links.map((x, j) => j === i ? { ...x, url: e.target.value } : x))} placeholder="https://" className="input-standard flex-1 text-sm" />
|
||||
<button type="button" onClick={() => set('links', draft.links.filter((_, j) => j !== i))} className="text-txt-tertiary hover:text-accent-rose text-lg leading-none px-1">×</button>
|
||||
</div>
|
||||
))}
|
||||
{draft.links.length < PROFILE_LIMITS.links && (
|
||||
<button type="button" onClick={() => set('links', [...draft.links, { label: '', url: '' }])} className="text-sm text-accent-primary hover:underline">+ {t('profile.editor.addLink')}</button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Board */}
|
||||
<Section title={t('profile.editor.board')} hint={t('profile.editor.board.hint')}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.widgets.favoriteGame')}</div>
|
||||
{draft.widgets.favoriteGame
|
||||
? <GameEditor game={draft.widgets.favoriteGame} withNote onChange={(g) => setW('favoriteGame', g)} onRemove={() => setW('favoriteGame', null)} />
|
||||
: <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('favoriteGame', g)} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.widgets.rotation')} · {draft.widgets.rotation.length}/{PROFILE_LIMITS.rotation}</div>
|
||||
<div className="space-y-2">
|
||||
{draft.widgets.rotation.map((g, i) => <GameEditor key={i} game={g} onChange={(n) => setW('rotation', draft.widgets.rotation.map((x, j) => j === i ? n : x))} onRemove={() => setW('rotation', draft.widgets.rotation.filter((_, j) => j !== i))} />)}
|
||||
{draft.widgets.rotation.length < PROFILE_LIMITS.rotation && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('rotation', [...draft.widgets.rotation, g])} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.widgets.wishlist')} · {draft.widgets.wishlist.length}/{PROFILE_LIMITS.wishlist}</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{draft.widgets.wishlist.map((g, i) => (
|
||||
<div key={i} className="relative w-[52px]" title={g.name}>
|
||||
{g.cover ? <img src={g.cover} alt="" className="w-[52px] h-[78px] rounded object-cover bg-black/40" /> : <div className="w-[52px] h-[78px] rounded bg-white/[0.06] flex items-center justify-center text-txt-tertiary text-xs px-1 text-center">{g.name}</div>}
|
||||
<button type="button" onClick={() => setW('wishlist', draft.widgets.wishlist.filter((_, j) => j !== i))} className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-black/80 text-white text-xs">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{draft.widgets.wishlist.length < PROFILE_LIMITS.wishlist && <GameSearch placeholder={t('profile.editor.searchGame')} onPick={(g) => setW('wishlist', [...draft.widgets.wishlist, g])} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-secondary mb-1.5">{t('profile.editor.quote')}</div>
|
||||
<textarea value={draft.widgets.quote?.text ?? ''} maxLength={PROFILE_LIMITS.quoteText} rows={2} onChange={(e) => setW('quote', e.target.value ? { text: e.target.value, author: draft.widgets.quote?.author ?? '' } : null)} placeholder={t('profile.editor.quote.placeholder')} className="input-standard w-full text-sm resize-none" />
|
||||
{draft.widgets.quote && <input type="text" value={draft.widgets.quote.author} maxLength={PROFILE_LIMITS.quoteAuthor} onChange={(e) => setW('quote', { text: draft.widgets.quote!.text, author: e.target.value })} placeholder={t('profile.editor.quote.author')} className="input-standard w-full text-sm mt-1.5" />}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Live preview */}
|
||||
<aside className="xl:sticky xl:top-0">
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('profile.editor.preview')}</div>
|
||||
<div className="rounded-[12px] overflow-hidden glass-modal select-none">
|
||||
<ProfileCard user={preview} style={draft} displayName={displayName} bannerSrc={bannerSrc} />
|
||||
</div>
|
||||
<p className="text-[11px] text-txt-tertiary mt-2">{t('profile.editor.preview.hint')}</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{error && <div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>}
|
||||
|
||||
{(isDirty || saving) && (
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||
<button onClick={handleReset} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors">{t('profile.editor.resetAll')}</button>
|
||||
<button onClick={() => setDraft(saved)} className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors">{t('profile.editor.discard')}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50">
|
||||
{saving ? t('profile.editor.saving') : t('profile.editor.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import type { AvatarDecoration } from '@backspace/shared/src/profile';
|
||||
|
||||
interface DecoratedAvatarProps {
|
||||
decoration: AvatarDecoration;
|
||||
/** Avatar element to decorate; the overlay sizes itself to this box. */
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an avatar with a decorative layer. Every decoration is inline SVG on a
|
||||
* 100×100 box around the picture — no image assets, so a new one is a few
|
||||
* lines of markup and scales to any avatar size.
|
||||
*/
|
||||
export function DecoratedAvatar({ decoration, children, className = '' }: DecoratedAvatarProps) {
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`}>
|
||||
{children}
|
||||
{decoration !== 'none' && (
|
||||
<div className="avatar-deco" aria-hidden="true">
|
||||
<Decoration id={decoration} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Decoration({ id }: { id: AvatarDecoration }) {
|
||||
switch (id) {
|
||||
case 'ring':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-spin-slow">
|
||||
<defs>
|
||||
<linearGradient id="deco-ring-g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stopColor="#a78bfa" /><stop offset="0.5" stopColor="#f472b6" /><stop offset="1" stopColor="#fbbf24" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="46" fill="none" stroke="url(#deco-ring-g)" strokeWidth="4" />
|
||||
</svg>
|
||||
);
|
||||
case 'neon':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-pulse">
|
||||
<circle cx="50" cy="50" r="46" fill="none" stroke="#22d3ee" strokeWidth="3" style={{ filter: 'drop-shadow(0 0 4px #22d3ee) drop-shadow(0 0 10px #a855f7)' }} />
|
||||
</svg>
|
||||
);
|
||||
case 'flames':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-flicker" style={{ transformOrigin: '50% 100%' }}>
|
||||
<defs>
|
||||
<linearGradient id="deco-fire-g" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stopColor="#ef4444" /><stop offset="0.6" stopColor="#f97316" /><stop offset="1" stopColor="#fde047" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#deco-fire-g)" opacity="0.95"
|
||||
d="M50 2 C56 12 62 14 60 24 C68 20 72 12 74 6 C78 18 86 22 86 34 C92 30 94 24 94 20 C98 34 98 48 92 60 C98 62 100 56 100 52 C98 74 84 92 64 98 C50 100 36 98 26 90 C12 82 2 66 4 48 C6 44 10 46 12 50 C10 38 14 30 20 24 C22 30 26 34 30 34 C28 22 34 12 42 6 C44 12 46 14 50 14 Z" />
|
||||
<circle cx="50" cy="52" r="42" fill="#0b0b10" />
|
||||
</svg>
|
||||
);
|
||||
case 'stars':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-spin">
|
||||
{[0, 72, 144, 216, 288].map((a) => (
|
||||
<g key={a} transform={`rotate(${a} 50 50)`}>
|
||||
<path transform="translate(50 6) scale(0.55)" fill="#fde68a" stroke="#f59e0b" strokeWidth="1"
|
||||
d="M0 -10 L2.9 -3.1 L10 -3.1 L4.2 1.2 L6.2 8.1 L0 4 L-6.2 8.1 L-4.2 1.2 L-10 -3.1 L-2.9 -3.1 Z" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
case 'halo':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-float">
|
||||
<ellipse cx="50" cy="8" rx="30" ry="7" fill="none" stroke="#fde68a" strokeWidth="4" style={{ filter: 'drop-shadow(0 0 6px #fbbf24)' }} />
|
||||
</svg>
|
||||
);
|
||||
case 'vines':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="46" fill="none" stroke="#166534" strokeWidth="3.5" />
|
||||
{[20, 70, 130, 200, 260, 320].map((a) => (
|
||||
<g key={a} transform={`rotate(${a} 50 50) translate(50 4)`}>
|
||||
<path d="M0 0 C-6 -4 -8 4 -3 8 C2 4 4 -2 0 0 Z" fill="#22c55e" />
|
||||
<path d="M0 0 C6 -4 8 4 3 8 C-2 4 -4 -2 0 0 Z" fill="#4ade80" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
case 'pixel':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" shapeRendering="crispEdges">
|
||||
{Array.from({ length: 28 }).map((_, i) => {
|
||||
const a = (i / 28) * Math.PI * 2;
|
||||
const x = 50 + Math.cos(a) * 45; const y = 50 + Math.sin(a) * 45;
|
||||
return <rect key={i} x={Math.round(x / 5) * 5 - 2.5} y={Math.round(y / 5) * 5 - 2.5} width="5" height="5" fill={i % 2 ? '#a855f7' : '#22d3ee'} />;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
case 'hearts':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-float">
|
||||
{[[8, 20, 1], [88, 14, 0.8], [6, 74, 0.7], [90, 78, 0.9]].map(([x, y, s], i) => (
|
||||
<path key={i} transform={`translate(${x} ${y}) scale(${s})`} fill="#fb7185"
|
||||
d="M0 6 C-8 -2 -8 -10 -2 -10 C1 -10 0 -8 0 -6 C0 -8 -1 -10 2 -10 C8 -10 8 -2 0 6 Z" />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
case 'frost':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-spin-slow">
|
||||
<circle cx="50" cy="50" r="46" fill="none" stroke="#bae6fd" strokeWidth="2" strokeDasharray="3 5" opacity="0.9" />
|
||||
{[0, 60, 120, 180, 240, 300].map((a) => (
|
||||
<g key={a} transform={`rotate(${a} 50 50) translate(50 3)`} stroke="#e0f2fe" strokeWidth="1.6" strokeLinecap="round" style={{ filter: 'drop-shadow(0 0 3px #7dd3fc)' }}>
|
||||
<line x1="0" y1="0" x2="0" y2="10" /><line x1="-4" y1="3" x2="4" y2="7" /><line x1="4" y1="3" x2="-4" y2="7" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
case 'crown':
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className="deco-float">
|
||||
<path transform="translate(50 6)" fill="#fbbf24" stroke="#b45309" strokeWidth="1.5" strokeLinejoin="round"
|
||||
d="M-22 6 L-28 -12 L-14 -2 L0 -16 L14 -2 L28 -12 L22 6 Z" />
|
||||
<rect x="28" y="12" width="44" height="5" rx="1.5" fill="#f59e0b" stroke="#b45309" strokeWidth="1" />
|
||||
<circle cx="36" cy="-4" r="2.4" fill="#f87171" /><circle cx="50" cy="-8" r="2.4" fill="#60a5fa" /><circle cx="64" cy="-4" r="2.4" fill="#4ade80" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from '../ui/Tooltip';
|
||||
import { useT } from '../../i18n';
|
||||
import type { BadgeId } from '../../utils/profileStyle';
|
||||
|
||||
const BADGE: Record<BadgeId, { color: string; path: string }> = {
|
||||
admin: { color: '#f87171', path: 'M12 2 4 5v6c0 5.5 3.8 10.7 8 12 4.2-1.3 8-6.5 8-12V5l-8-3zm-1 14-4-4 1.4-1.4L11 13.2l5.6-5.6L18 9l-7 7z' },
|
||||
owner: { color: '#fbbf24', path: 'M5 16 3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm14 3H5v-2h14v2z' },
|
||||
founder: { color: '#34d399', path: 'M12 2a5 5 0 0 1 5 5c0 2.6-2 4.8-4.5 5H12v9h-2v-9h-.5A5 5 0 0 1 12 2zm0 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6z' },
|
||||
veteran: { color: '#a78bfa', path: 'M12 2 14.9 8.6 22 9.3l-5.4 4.7L18.2 21 12 17.3 5.8 21l1.6-7L2 9.3l7.1-.7L12 2z' },
|
||||
og: { color: '#60a5fa', path: 'M12 22a10 10 0 1 1 0-20 10 10 0 0 1 0 20zm0-18a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm1 3v5.6l4 2.4-.8 1.3L11 13V7h2z' },
|
||||
stylist: { color: '#f472b6', path: 'M12 3 9.6 8.6 3.5 9.4l4.5 4-1.3 6.1L12 16.4l5.3 3.1-1.3-6.1 4.5-4-6.1-.8L12 3z' },
|
||||
};
|
||||
|
||||
/** Row of derived badges (see computeBadges) with a tooltip each. */
|
||||
export function ProfileBadges({ badges, size = 18 }: { badges: BadgeId[]; size?: number }) {
|
||||
const t = useT();
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{badges.map((id) => (
|
||||
<Tooltip key={id} content={t(`profile.badge.${id}`)} position="top">
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-md"
|
||||
style={{ width: size + 6, height: size + 6, background: BADGE[id].color + '22' }}
|
||||
>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill={BADGE[id].color}><path d={BADGE[id].path} /></svg>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { User, Space, MemberWithUser } from '@backspace/shared';
|
||||
import type { ProfileStyle } from '@backspace/shared/src/profile';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { Username } from '../ui/Username';
|
||||
import { StyledName } from './StyledName';
|
||||
import { DecoratedAvatar } from './DecoratedAvatar';
|
||||
import { ProfileEffectLayer } from './ProfileEffectLayer';
|
||||
import { ProfileTagChip } from './ProfileTagChip';
|
||||
import { ProfileBadges } from './ProfileBadges';
|
||||
import { ProfileLinks } from './ProfileLinks';
|
||||
import { GameCollectionStrip } from './ProfileWidgets';
|
||||
import { computeBadges, themeGradient } from '../../utils/profileStyle';
|
||||
import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
export interface ProfileCardProps {
|
||||
user: User;
|
||||
style: ProfileStyle;
|
||||
displayName: string;
|
||||
bannerSrc: string | null;
|
||||
space?: Pick<Space, 'ownerId' | 'createdAt'> | null;
|
||||
member?: Pick<MemberWithUser, 'joinedAt'> | null;
|
||||
onAvatarClick?: (e: React.MouseEvent) => void;
|
||||
/** Rendered under the identity block — the popout's mutuals + buttons. */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual body of a profile card. Used by the popout and, unchanged, as the
|
||||
* live preview in the profile editor — what you see while editing is exactly
|
||||
* the component others will see.
|
||||
*/
|
||||
export function ProfileCard({ user, style, displayName, bannerSrc, space, member, onAvatarClick, children }: ProfileCardProps) {
|
||||
const t = useT();
|
||||
const theme = themeGradient(style);
|
||||
const bannerFallback = theme
|
||||
?? (user.accentColor
|
||||
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
|
||||
: (() => { const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor); return mutedGradient(g.from, g.to); })());
|
||||
const badges = computeBadges(user, { space, member });
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Theme wash behind the whole card */}
|
||||
{theme && <div className="absolute inset-0 opacity-[0.22]" style={{ background: theme }} aria-hidden="true" />}
|
||||
|
||||
{/* Banner */}
|
||||
<div
|
||||
className="h-[88px] relative"
|
||||
style={bannerSrc
|
||||
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: { background: bannerFallback }}
|
||||
>
|
||||
<ProfileEffectLayer effect={style.effect} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 relative">
|
||||
<div className="flex items-end justify-between mt-[-44px] mb-2.5">
|
||||
<DecoratedAvatar decoration={style.decoration}>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
name={displayName}
|
||||
size={80}
|
||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||
userId={user.homeUserId ?? user.id}
|
||||
user={user}
|
||||
onClick={onAvatarClick}
|
||||
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
|
||||
/>
|
||||
</DecoratedAvatar>
|
||||
<div className="mb-1"><ProfileBadges badges={badges} /></div>
|
||||
</div>
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StyledName name={displayName} style={style.name} className="text-[18px] font-bold leading-tight" />
|
||||
{style.tag && <ProfileTagChip tag={style.tag} />}
|
||||
</div>
|
||||
<div className="text-[13px] text-txt-tertiary flex items-center gap-1.5 flex-wrap">
|
||||
<Username username={user.username} showAt className="text-[13px] text-txt-tertiary" />
|
||||
{style.pronouns && <><span className="opacity-50">•</span><span>{style.pronouns}</span></>}
|
||||
</div>
|
||||
{user.customStatus && <div className="text-[13px] text-txt-secondary italic mt-1">{user.customStatus}</div>}
|
||||
|
||||
{/* Bio */}
|
||||
{user.bio && (
|
||||
<>
|
||||
<div className="border-t border-white/[0.06] my-3" />
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">{t('profile.aboutMe')}</span>
|
||||
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{ a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer">{children}</a> }}
|
||||
>
|
||||
{user.bio}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{style.links.length > 0 && <div className="mt-3"><ProfileLinks links={style.links} compact /></div>}
|
||||
|
||||
<div className="mt-3"><GameCollectionStrip widgets={style.widgets} /></div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { ProfileEffect } from '@backspace/shared/src/profile';
|
||||
|
||||
interface ProfileEffectLayerProps {
|
||||
effect: ProfileEffect;
|
||||
/** Particle count scale; the popout banner is small, the modal's is wider. */
|
||||
density?: number;
|
||||
}
|
||||
|
||||
const CONFETTI = ['#f472b6', '#fbbf24', '#34d399', '#60a5fa', '#a78bfa'];
|
||||
const MATRIX = 'アイウエオ01カキクケコ';
|
||||
|
||||
/** Small deterministic PRNG so particles do not jump on every re-render. */
|
||||
function seeded(n: number): () => number {
|
||||
let s = n * 9301 + 49297;
|
||||
return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated overlay for the banner. Pure CSS motion on a handful of spans; the
|
||||
* layout of the spans is seeded so React can re-render the card freely
|
||||
* without the weather changing.
|
||||
*/
|
||||
export function ProfileEffectLayer({ effect, density = 1 }: ProfileEffectLayerProps) {
|
||||
const particles = useMemo(() => {
|
||||
if (effect === 'none' || effect === 'aurora') return [];
|
||||
const count = Math.round((effect === 'rain' ? 26 : effect === 'matrix' ? 14 : 16) * density);
|
||||
const rnd = seeded(count * 7 + effect.length);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
left: `${Math.round(rnd() * 100)}%`,
|
||||
top: effect === 'sparkles' ? `${Math.round(rnd() * 90)}%` : undefined,
|
||||
delay: `${(rnd() * 4).toFixed(2)}s`,
|
||||
dur: `${(1.6 + rnd() * 3).toFixed(2)}s`,
|
||||
size: `${6 + Math.round(rnd() * 10)}px`,
|
||||
color: CONFETTI[i % CONFETTI.length]!,
|
||||
glyph: effect === 'matrix' ? MATRIX[Math.floor(rnd() * MATRIX.length)] : effect === 'hearts' ? '❤' : '',
|
||||
}));
|
||||
}, [effect, density]);
|
||||
|
||||
if (effect === 'none') return null;
|
||||
|
||||
return (
|
||||
<div className={`profile-fx fx-${effect}`} aria-hidden="true">
|
||||
{particles.map((p, i) => (
|
||||
<i
|
||||
key={i}
|
||||
style={{
|
||||
left: p.left,
|
||||
top: p.top ?? (effect === 'embers' || effect === 'bubbles' || effect === 'hearts' ? '100%' : '-10%'),
|
||||
'--t': p.delay, '--d': p.dur, '--k': p.color, '--s': p.size,
|
||||
color: effect === 'hearts' ? p.color : undefined,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{p.glyph}
|
||||
</i>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import type { ProfileLink } from '@backspace/shared/src/profile';
|
||||
import { detectLinkIcon, type LinkIconId } from '../../utils/profileStyle';
|
||||
|
||||
const ICON: Record<LinkIconId, string> = {
|
||||
steam: 'M12 2a10 10 0 0 0-9.96 9.1l5.36 2.2a2.8 2.8 0 0 1 1.6-.5h.2l2.4-3.4v-.1a3.8 3.8 0 1 1 3.8 3.8h-.1l-3.4 2.4v.2a2.8 2.8 0 0 1-5.6.3L2.6 14.6A10 10 0 1 0 12 2zm-3.4 15.2 1.3.5a2.1 2.1 0 1 0-1.1-2.7l1.3.5a1.5 1.5 0 1 1-1.5 1.7zm6.8-6a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm0-4.4a1.9 1.9 0 1 1 0 3.8 1.9 1.9 0 0 1 0-3.8z',
|
||||
twitch: 'M4 2 2 6v14h5v3h3l3-3h4l5-5V2H4zm16 12-3 3h-5l-3 3v-3H6V4h14v10zM15 7h2v5h-2V7zm-5 0h2v5h-2V7z',
|
||||
youtube: 'M23 7.2a3 3 0 0 0-2.1-2.1C19 4.6 12 4.6 12 4.6s-7 0-8.9.5A3 3 0 0 0 1 7.2 31 31 0 0 0 .5 12a31 31 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5a3 3 0 0 0 2.1-2.1 31 31 0 0 0 .5-4.8 31 31 0 0 0-.5-4.8zM9.7 15.1V8.9l6 3.1-6 3.1z',
|
||||
instagram: 'M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0 8a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm5.2-8.4a1.2 1.2 0 1 1-2.4 0 1.2 1.2 0 0 1 2.4 0zM21 8.7c-.1-1.6-.4-3-1.6-4.2S16.9 3 15.3 3C13.6 2.9 10.4 2.9 8.7 3 7.1 3 5.7 3.4 4.5 4.5S3 7.1 3 8.7C2.9 10.4 2.9 13.6 3 15.3c.1 1.6.4 3 1.6 4.2s2.6 1.5 4.2 1.6c1.7.1 4.9.1 6.6 0 1.6-.1 3-.4 4.2-1.6s1.5-2.6 1.6-4.2c.1-1.7.1-4.9 0-6.6zM19 17a3 3 0 0 1-1.7 1.7c-1.2.5-4 .4-5.3.4s-4.1.1-5.3-.4A3 3 0 0 1 5 17c-.5-1.2-.4-4-.4-5.3s-.1-4.1.4-5.3A3 3 0 0 1 6.7 4.7c1.2-.5 4-.4 5.3-.4s4.1-.1 5.3.4A3 3 0 0 1 19 6.4c.5 1.2.4 4 .4 5.3s.1 4.1-.4 5.3z',
|
||||
github: 'M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.9c-2.8.6-3.4-1.2-3.4-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.6 2.4 1.1 3 .9.1-.7.4-1.1.6-1.4-2.2-.3-4.6-1.1-4.6-5a3.9 3.9 0 0 1 1-2.7 3.6 3.6 0 0 1 .1-2.7s.9-.3 2.8 1a9.5 9.5 0 0 1 5 0c1.9-1.3 2.8-1 2.8-1a3.6 3.6 0 0 1 .1 2.7 3.9 3.9 0 0 1 1 2.7c0 3.9-2.4 4.7-4.6 5 .4.3.7.9.7 1.9v2.8c0 .3.2.6.7.5A10 10 0 0 0 12 2z',
|
||||
x: 'M17.5 3h3.1l-6.8 7.7L21.8 21h-6.3l-4.9-6.4L5 21H1.9l7.3-8.3L1.5 3h6.4l4.4 5.9L17.5 3zm-1.1 16.2h1.7L6.9 4.7H5.1l11.3 14.5z',
|
||||
tiktok: 'M16.6 5.8A4.3 4.3 0 0 1 15.5 3h-3.1v12.4a2.6 2.6 0 1 1-1.8-2.5V9.7a5.8 5.8 0 1 0 5 5.7V9.1a7.3 7.3 0 0 0 4.3 1.4V7.4a4.3 4.3 0 0 1-3.3-1.6z',
|
||||
spotify: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm4.6 14.4a.6.6 0 0 1-.9.2c-2.4-1.5-5.4-1.8-8.9-1a.6.6 0 0 1-.3-1.2c3.9-.9 7.2-.5 9.9 1.1a.6.6 0 0 1 .2.9zm1.2-2.7a.8.8 0 0 1-1.1.3c-2.7-1.7-6.9-2.2-10.1-1.2a.8.8 0 0 1-.5-1.5c3.7-1.1 8.3-.6 11.4 1.3a.8.8 0 0 1 .3 1.1zm.1-2.9C14.6 8.9 9.2 8.7 6.1 9.6a.9.9 0 1 1-.5-1.8c3.6-1.1 9.5-.9 13.3 1.4a.9.9 0 0 1-1 1.6z',
|
||||
discord: 'M19.3 5.3A16.6 16.6 0 0 0 15.1 4l-.5 1a15.3 15.3 0 0 0-5.2 0l-.5-1a16.6 16.6 0 0 0-4.2 1.3C2.1 9.3 1.4 13.2 1.7 17a16.7 16.7 0 0 0 5.1 2.6l1.1-1.8a10.8 10.8 0 0 1-1.7-.8l.4-.3a11.9 11.9 0 0 0 10.8 0l.4.3a10.8 10.8 0 0 1-1.7.8l1.1 1.8a16.7 16.7 0 0 0 5.1-2.6c.4-4.4-.7-8.3-3-11.7zM8.7 14.6c-1 0-1.8-.9-1.8-2s.8-2 1.8-2 1.8.9 1.8 2-.8 2-1.8 2zm6.6 0c-1 0-1.8-.9-1.8-2s.8-2 1.8-2 1.8.9 1.8 2-.8 2-1.8 2z',
|
||||
reddit: 'M22 12.1a2.2 2.2 0 0 0-3.7-1.6 10.8 10.8 0 0 0-5.8-1.8l1-4.6 3.2.7a1.6 1.6 0 1 0 .2-1l-3.6-.8a.5.5 0 0 0-.6.4l-1.1 5.3a10.9 10.9 0 0 0-5.9 1.8 2.2 2.2 0 1 0-2.4 3.6 4.3 4.3 0 0 0 0 .7c0 3.4 3.9 6.1 8.7 6.1s8.7-2.7 8.7-6.1a4.3 4.3 0 0 0 0-.7 2.2 2.2 0 0 0 1.3-2zM7 13.6a1.6 1.6 0 1 1 3.2 0 1.6 1.6 0 0 1-3.2 0zm8.9 4.2a5.9 5.9 0 0 1-3.9 1.2 5.9 5.9 0 0 1-3.9-1.2.4.4 0 0 1 .6-.6 5 5 0 0 0 3.3 1 5 5 0 0 0 3.3-1 .4.4 0 0 1 .6.6zm-.5-2.6a1.6 1.6 0 1 1 0-3.2 1.6 1.6 0 0 1 0 3.2z',
|
||||
kick: 'M3 3h6v6h3V6h3V3h6v6h-3v3h3v6h-6v-3h-3v3h-3v3H3V3z',
|
||||
link: 'M3.9 12a4.1 4.1 0 0 1 4.1-4.1h4V6H8a6 6 0 0 0 0 12h4v-1.9H8A4.1 4.1 0 0 1 3.9 12zM8 13h8v-2H8v2zm8-7h-4v1.9h4a4.1 4.1 0 0 1 0 8.2h-4V18h4a6 6 0 0 0 0-12z',
|
||||
};
|
||||
|
||||
export function LinkIcon({ id, size = 16, className = '' }: { id: LinkIconId; size?: number; className?: string }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}><path d={ICON[id]} /></svg>;
|
||||
}
|
||||
|
||||
/** The user's links, each with a brand icon picked from its host. */
|
||||
export function ProfileLinks({ links, compact = false }: { links: ProfileLink[]; compact?: boolean }) {
|
||||
if (links.length === 0) return null;
|
||||
return (
|
||||
<div className={compact ? 'flex flex-wrap gap-1.5' : 'space-y-1'}>
|
||||
{links.map((l, i) => {
|
||||
const icon = detectLinkIcon(l.url);
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={compact
|
||||
? 'inline-flex items-center gap-1 px-2 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.1] text-[12px] text-txt-secondary hover:text-txt-primary transition-colors'
|
||||
: 'flex items-center gap-2 px-2.5 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] text-[13px] text-txt-secondary hover:text-txt-primary transition-colors'}
|
||||
>
|
||||
<LinkIcon id={icon} size={compact ? 13 : 15} className="flex-shrink-0 opacity-80" />
|
||||
<span className="truncate">{l.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import type { ProfileTag } from '@backspace/shared/src/profile';
|
||||
|
||||
/** The clan-style tag beside the name: emoji + 2–4 uppercase letters. */
|
||||
export function ProfileTagChip({ tag, className = '' }: { tag: ProfileTag; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-[5px] px-1.5 py-[1px] text-[11px] font-bold tracking-wide leading-[1.4] ${className}`}
|
||||
style={{ color: tag.color, backgroundColor: tag.color + '26', border: `1px solid ${tag.color}55` }}
|
||||
>
|
||||
{tag.emoji && <span className="text-[12px] leading-none">{tag.emoji}</span>}
|
||||
{tag.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import type { GameEntry, ProfileWidgets as Widgets } from '@backspace/shared/src/profile';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
function Cover({ game, size }: { game: GameEntry; size: number }) {
|
||||
const w = size; const h = Math.round(size * 1.5);
|
||||
return game.cover ? (
|
||||
<img src={game.cover} alt="" width={w} height={h} loading="lazy" className="rounded-md object-cover flex-shrink-0 bg-black/40" style={{ width: w, height: h }} />
|
||||
) : (
|
||||
<div className="rounded-md flex-shrink-0 flex items-center justify-center bg-white/[0.06] text-txt-tertiary font-bold" style={{ width: w, height: h, fontSize: size * 0.4 }}>
|
||||
{game.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameRow({ game, size = 44 }: { game: GameEntry; size?: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Cover game={game} size={size} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-txt-primary truncate">{game.name}</div>
|
||||
{game.note && <div className="text-[12px] text-txt-secondary italic truncate">{game.note}</div>}
|
||||
{game.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{game.tags.map((tg) => (
|
||||
<span key={tg} className="px-1.5 py-[1px] rounded text-[10px] bg-white/[0.08] text-txt-secondary">{tg}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact strip for the popout: a few covers and a "+N", like a collection. */
|
||||
export function GameCollectionStrip({ widgets }: { widgets: Widgets }) {
|
||||
const t = useT();
|
||||
const all = [widgets.favoriteGame, ...widgets.rotation].filter((g): g is GameEntry => g !== null);
|
||||
if (all.length === 0) return null;
|
||||
const shown = all.slice(0, 3);
|
||||
const rest = all.length - shown.length;
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg bg-white/[0.04] px-3 py-2">
|
||||
<span className="text-[12px] font-semibold text-txt-primary">{t('profile.widgets.collection')}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shown.map((g, i) => <Cover key={i} game={g} size={18} />)}
|
||||
{rest > 0 && <span className="ml-1 text-[11px] text-txt-tertiary">+{rest}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full board for the profile modal. */
|
||||
export function ProfileBoard({ widgets }: { widgets: Widgets }) {
|
||||
const t = useT();
|
||||
const empty = !widgets.favoriteGame && widgets.rotation.length === 0 && widgets.wishlist.length === 0 && !widgets.quote;
|
||||
if (empty) return <div className="text-center py-8 text-txt-tertiary text-sm">{t('profile.widgets.empty')}</div>;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{widgets.quote && (
|
||||
<blockquote className="border-l-2 border-accent-primary/70 pl-3 py-1">
|
||||
<div className="text-[14px] text-txt-primary leading-snug">{widgets.quote.text}</div>
|
||||
{widgets.quote.author && <div className="text-[12px] text-txt-tertiary mt-1">— {widgets.quote.author}</div>}
|
||||
</blockquote>
|
||||
)}
|
||||
{widgets.favoriteGame && (
|
||||
<section className="rounded-xl bg-white/[0.04] border border-white/[0.05] p-3">
|
||||
<div className="text-[12px] font-bold text-txt-primary mb-2">{t('profile.widgets.favoriteGame')}</div>
|
||||
<GameRow game={widgets.favoriteGame} size={56} />
|
||||
</section>
|
||||
)}
|
||||
{widgets.rotation.length > 0 && (
|
||||
<section className="rounded-xl bg-white/[0.04] border border-white/[0.05] p-3">
|
||||
<div className="text-[12px] font-bold text-txt-primary mb-2">{t('profile.widgets.rotation')}</div>
|
||||
<div className="space-y-2.5">{widgets.rotation.map((g, i) => <GameRow key={i} game={g} />)}</div>
|
||||
</section>
|
||||
)}
|
||||
{widgets.wishlist.length > 0 && (
|
||||
<section className="rounded-xl bg-white/[0.04] border border-white/[0.05] p-3">
|
||||
<div className="text-[12px] font-bold text-txt-primary mb-2">{t('profile.widgets.wishlist')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{widgets.wishlist.map((g, i) => (
|
||||
<div key={i} className="w-[64px]" title={g.name}>
|
||||
<Cover game={g} size={64} />
|
||||
<div className="text-[10px] text-txt-secondary truncate mt-1">{g.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import type { NameStyle } from '@backspace/shared/src/profile';
|
||||
import { nameStyleProps } from '../../utils/profileStyle';
|
||||
|
||||
interface StyledNameProps {
|
||||
name: string;
|
||||
style: NameStyle;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A display name rendered with the user's chosen font and effect. */
|
||||
export function StyledName({ name, style, className = '' }: StyledNameProps) {
|
||||
const p = nameStyleProps(style);
|
||||
return (
|
||||
<span className={`${p.className} ${className}`} style={p.style} data-text={name}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,11 @@ import type { User } from '@backspace/shared';
|
||||
vi.mock('../../stores/spaceStore', () => ({
|
||||
useSpaceStore: Object.assign(
|
||||
(selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }),
|
||||
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }) },
|
||||
// The card now reads the space roster to derive badges; the stub must
|
||||
// carry the same shape the store does, or the selectors dereference
|
||||
// undefined and the failure looks like a component bug.
|
||||
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn(), spaces: [], members: [], currentSpaceId: null }),
|
||||
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn(), spaces: [], members: [], currentSpaceId: null }) },
|
||||
),
|
||||
getApiForOrigin: () => ({ uploads: { url: (k: string) => `/uploads/${k}` } }),
|
||||
resolveUserOrigin: () => 'local',
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { Username } from '../ui/Username';
|
||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||
import { api } from '../../api/client';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients';
|
||||
import { parseFederatedUsername } from '../../utils/identity';
|
||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||
import { loadFederatedMutuals } from '../../utils/mutuals';
|
||||
import { computeFloatingPosition, type AnchorRect, type Placement } from '../../hooks/useFloatingPosition';
|
||||
import { ProfileCard } from '../profile/ProfileCard';
|
||||
import { getProfileStyle } from '../../utils/profileStyle';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
/** Gap between the card and the element it was opened from. */
|
||||
const ANCHOR_OFFSET = 8;
|
||||
@@ -26,6 +25,7 @@ interface UserProfilePopoutProps {
|
||||
|
||||
export function UserProfilePopout({ user: propUser, onClose, anchor, placement = 'right' }: UserProfilePopoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const t = useT();
|
||||
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
// Resolve to the best-known view of this user from the userViews cache.
|
||||
@@ -119,12 +119,12 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
|
||||
const bannerSrc = user.banner
|
||||
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
|
||||
: null;
|
||||
const bannerFallback = user.accentColor
|
||||
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
|
||||
: (() => {
|
||||
const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor);
|
||||
return mutedGradient(g.from, g.to);
|
||||
})();
|
||||
const style = getProfileStyle(user);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const space = spaces.find((sp) => sp.id === currentSpaceId) ?? null;
|
||||
const member = members.find((m) => m.userId === user.id) ?? null;
|
||||
|
||||
// Parked off-screen for the one layout pass before the card knows how tall it
|
||||
// is; `useLayoutEffect` places it before the browser paints, so it never
|
||||
@@ -138,81 +138,22 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
|
||||
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
|
||||
style={cardStyle}
|
||||
>
|
||||
{/* Banner */}
|
||||
<div
|
||||
className="h-[80px] rounded-t-[12px]"
|
||||
style={bannerSrc
|
||||
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: { background: bannerFallback }
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 pb-4 relative">
|
||||
{/* Avatar */}
|
||||
{/* The picture escalates to the full profile — the card is a preview, and
|
||||
clicking the face is the obvious way to ask for the whole thing. It
|
||||
deliberately does NOT reopen the card (see issue #37). */}
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
name={displayName}
|
||||
size={80}
|
||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||
userId={user.homeUserId ?? user.id}
|
||||
user={user}
|
||||
onClick={handleAvatarClick}
|
||||
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
|
||||
className="mt-[-44px] mb-3"
|
||||
/>
|
||||
|
||||
{/* Name & info */}
|
||||
<div>
|
||||
<Username
|
||||
username={user.displayName ?? baseName}
|
||||
className="text-[16px] font-semibold leading-tight"
|
||||
/>
|
||||
<div className="text-[13px] text-txt-tertiary">
|
||||
<Username username={user.username} showAt className="text-[13px] text-txt-tertiary" />
|
||||
</div>
|
||||
{user.customStatus && (
|
||||
<div className="text-[13px] text-txt-secondary italic mt-1">
|
||||
{user.customStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
{user.bio && (
|
||||
<>
|
||||
<div className="border-t border-white/[0.06] my-3" />
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
About Me
|
||||
</span>
|
||||
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{user.bio}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ProfileCard
|
||||
user={user}
|
||||
style={style}
|
||||
displayName={displayName}
|
||||
bannerSrc={bannerSrc}
|
||||
space={space}
|
||||
member={member}
|
||||
onAvatarClick={handleAvatarClick}
|
||||
>
|
||||
<div className="border-t border-white/[0.06] my-3" />
|
||||
|
||||
{/* Member since + Mutuals */}
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
Member Since
|
||||
{t('profile.memberSince')}
|
||||
</span>
|
||||
<span className="text-[12px] text-txt-secondary ml-2">
|
||||
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
@@ -238,15 +179,15 @@ export function UserProfilePopout({ user: propUser, onClose, anchor, placement =
|
||||
onClick={handleSendMessage}
|
||||
className="w-full mt-3 py-2 rounded-lg text-[13px] font-medium text-txt-primary bg-white/[0.06] hover:bg-white/[0.10] border border-white/[0.08] transition-colors"
|
||||
>
|
||||
Send Message
|
||||
{t('profile.sendMessage')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleViewFullProfile}
|
||||
className="w-full mt-1.5 py-2 rounded-lg text-[13px] font-medium text-txt-tertiary hover:text-txt-secondary bg-transparent hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
View Full Profile
|
||||
{t('profile.viewFull')}
|
||||
</button>
|
||||
</div>
|
||||
</ProfileCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1032,6 +1032,115 @@ export const en = {
|
||||
'system.pinned.seeAll': 'See all pinned messages.',
|
||||
'system.preview.pinned': '{actor} pinned a message',
|
||||
'join.page.back': 'Back to Resenhacord',
|
||||
// Profile identity — editor, catalogs, badges, widgets
|
||||
'settings.tab.profile': 'Profile',
|
||||
'profile.tab.board': 'Board',
|
||||
'profile.links': 'Links',
|
||||
'profile.viewFull': 'View Full Profile',
|
||||
'profile.editor.title': 'Profile',
|
||||
'profile.editor.subtitle': 'Make it yours. Everything here shows on your profile card, the full profile and the member list.',
|
||||
'profile.editor.preview': 'Preview',
|
||||
'profile.editor.preview.hint': 'This is exactly what others see when they click your name.',
|
||||
'profile.editor.name': 'Display name style',
|
||||
'profile.editor.name.hint': 'Font, effect and colours for your name on your profile.',
|
||||
'profile.editor.font': 'Font',
|
||||
'profile.editor.effect': 'Effect',
|
||||
'profile.editor.colors': 'Colours',
|
||||
'profile.editor.decoration': 'Avatar decoration',
|
||||
'profile.editor.profileEffect': 'Profile effect',
|
||||
'profile.editor.nameplate': 'Nameplate',
|
||||
'profile.editor.nameplate.hint': 'The background behind your row in the member list.',
|
||||
'profile.editor.theme': 'Theme',
|
||||
'profile.editor.theme.hint': 'Two colours washed behind your card and used when you have no banner.',
|
||||
'profile.editor.theme.none': 'Default',
|
||||
'profile.editor.identity': 'Identity',
|
||||
'profile.editor.pronouns': 'Pronouns',
|
||||
'profile.editor.pronouns.placeholder': 'he/him, she/her, they/them…',
|
||||
'profile.editor.tag': 'Tag',
|
||||
'profile.editor.tag.hint': '2–4 letters shown in a chip beside your name, with an optional emoji.',
|
||||
'profile.editor.links': 'Links',
|
||||
'profile.editor.links.hint': 'Up to 5. The icon is picked from the site automatically.',
|
||||
'profile.editor.linkLabel': 'Label',
|
||||
'profile.editor.addLink': 'Add link',
|
||||
'profile.editor.board': 'Board',
|
||||
'profile.editor.board.hint': 'Games and a quote, shown on the Board tab of your full profile.',
|
||||
'profile.editor.searchGame': 'Search a game on Steam, or type a title and press Enter',
|
||||
'profile.editor.searching': 'Searching…',
|
||||
'profile.editor.useTyped': 'Use "{name}" as typed',
|
||||
'profile.editor.gameNote': 'Why this one? (optional)',
|
||||
'profile.editor.gameTags': 'Tags, comma-separated (up to 3)',
|
||||
'profile.editor.quote': 'Quote',
|
||||
'profile.editor.quote.placeholder': 'A line that says something about you…',
|
||||
'profile.editor.quote.author': 'Who said it (optional)',
|
||||
'profile.editor.remove': 'Remove',
|
||||
'profile.editor.save': 'Save',
|
||||
'profile.editor.saving': 'Saving...',
|
||||
'profile.editor.saved': 'Profile updated',
|
||||
'profile.editor.saveFailed': 'Could not save the profile',
|
||||
'profile.editor.discard': 'Discard',
|
||||
'profile.editor.resetAll': 'Reset everything',
|
||||
'profile.font.default': 'Default',
|
||||
'profile.font.display': 'Display',
|
||||
'profile.font.comic': 'Comic',
|
||||
'profile.font.script': 'Script',
|
||||
'profile.font.pixel': 'Pixel',
|
||||
'profile.font.future': 'Future',
|
||||
'profile.font.serif': 'Serif',
|
||||
'profile.font.mono': 'Mono',
|
||||
'profile.font.round': 'Round',
|
||||
'profile.font.marker': 'Marker',
|
||||
'profile.effect.solid': 'Solid',
|
||||
'profile.effect.gradient': 'Gradient',
|
||||
'profile.effect.neon': 'Neon',
|
||||
'profile.effect.toon': 'Toon',
|
||||
'profile.effect.pop': 'Pop',
|
||||
'profile.effect.gummy': 'Gummy',
|
||||
'profile.effect.prism': 'Prism',
|
||||
'profile.effect.glitch': 'Glitch',
|
||||
'profile.effect.fire': 'Fire',
|
||||
'profile.decoration.none': 'None',
|
||||
'profile.decoration.ring': 'Ring',
|
||||
'profile.decoration.neon': 'Neon',
|
||||
'profile.decoration.flames': 'Flames',
|
||||
'profile.decoration.stars': 'Stars',
|
||||
'profile.decoration.halo': 'Halo',
|
||||
'profile.decoration.vines': 'Vines',
|
||||
'profile.decoration.pixel': 'Pixel',
|
||||
'profile.decoration.hearts': 'Hearts',
|
||||
'profile.decoration.frost': 'Frost',
|
||||
'profile.decoration.crown': 'Crown',
|
||||
'profile.profileEffect.none': 'None',
|
||||
'profile.profileEffect.sparkles': 'Sparkles',
|
||||
'profile.profileEffect.rain': 'Rain',
|
||||
'profile.profileEffect.snow': 'Snow',
|
||||
'profile.profileEffect.matrix': 'Matrix',
|
||||
'profile.profileEffect.confetti': 'Confetti',
|
||||
'profile.profileEffect.aurora': 'Aurora',
|
||||
'profile.profileEffect.embers': 'Embers',
|
||||
'profile.profileEffect.bubbles': 'Bubbles',
|
||||
'profile.profileEffect.hearts': 'Hearts',
|
||||
'profile.nameplate.none': 'None',
|
||||
'profile.nameplate.sunset': 'Sunset',
|
||||
'profile.nameplate.ocean': 'Ocean',
|
||||
'profile.nameplate.neon': 'Neon',
|
||||
'profile.nameplate.forest': 'Forest',
|
||||
'profile.nameplate.royal': 'Royal',
|
||||
'profile.nameplate.brasil': 'Brasil',
|
||||
'profile.nameplate.cyber': 'Cyber',
|
||||
'profile.nameplate.blossom': 'Blossom',
|
||||
'profile.nameplate.midnight': 'Midnight',
|
||||
'profile.nameplate.custom': 'Custom',
|
||||
'profile.badge.admin': 'Instance admin',
|
||||
'profile.badge.owner': 'Server owner',
|
||||
'profile.badge.founder': 'Founding member — joined in the first month',
|
||||
'profile.badge.veteran': 'Veteran — over a year here',
|
||||
'profile.badge.og': 'OG — over six months here',
|
||||
'profile.badge.stylist': 'Stylist — customised profile',
|
||||
'profile.widgets.collection': 'Game Collection',
|
||||
'profile.widgets.favoriteGame': 'Favorite Game',
|
||||
'profile.widgets.rotation': 'Games in Rotation',
|
||||
'profile.widgets.wishlist': 'Want to Play',
|
||||
'profile.widgets.empty': 'Nothing on the board yet.',
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
@@ -1031,4 +1031,113 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'system.pinned.seeAll': 'Ver todas as mensagens fixadas.',
|
||||
'system.preview.pinned': '{actor} fixou uma mensagem',
|
||||
'join.page.back': 'Voltar ao Resenhacord',
|
||||
// Identidade do perfil — editor, catálogos, emblemas, widgets
|
||||
'settings.tab.profile': 'Perfil',
|
||||
'profile.tab.board': 'Quadro',
|
||||
'profile.links': 'Links',
|
||||
'profile.viewFull': 'Ver perfil completo',
|
||||
'profile.editor.title': 'Perfil',
|
||||
'profile.editor.subtitle': 'Deixe com a sua cara. Tudo aqui aparece no seu cartão, no perfil completo e na lista de membros.',
|
||||
'profile.editor.preview': 'Prévia',
|
||||
'profile.editor.preview.hint': 'É exatamente isto que os outros veem ao clicar no seu nome.',
|
||||
'profile.editor.name': 'Estilo do nome',
|
||||
'profile.editor.name.hint': 'Fonte, efeito e cores do seu nome no perfil.',
|
||||
'profile.editor.font': 'Fonte',
|
||||
'profile.editor.effect': 'Efeito',
|
||||
'profile.editor.colors': 'Cores',
|
||||
'profile.editor.decoration': 'Decoração do avatar',
|
||||
'profile.editor.profileEffect': 'Efeito do perfil',
|
||||
'profile.editor.nameplate': 'Nameplate',
|
||||
'profile.editor.nameplate.hint': 'O fundo da sua linha na lista de membros.',
|
||||
'profile.editor.theme': 'Tema',
|
||||
'profile.editor.theme.hint': 'Duas cores por trás do seu cartão, usadas também quando você não tem banner.',
|
||||
'profile.editor.theme.none': 'Padrão',
|
||||
'profile.editor.identity': 'Identidade',
|
||||
'profile.editor.pronouns': 'Pronomes',
|
||||
'profile.editor.pronouns.placeholder': 'ele/dele, ela/dela, elu/delu…',
|
||||
'profile.editor.tag': 'Tag',
|
||||
'profile.editor.tag.hint': '2 a 4 letras num selo ao lado do nome, com emoji opcional.',
|
||||
'profile.editor.links': 'Links',
|
||||
'profile.editor.links.hint': 'Até 5. O ícone é escolhido pelo site automaticamente.',
|
||||
'profile.editor.linkLabel': 'Rótulo',
|
||||
'profile.editor.addLink': 'Adicionar link',
|
||||
'profile.editor.board': 'Quadro',
|
||||
'profile.editor.board.hint': 'Jogos e uma citação, na aba Quadro do seu perfil completo.',
|
||||
'profile.editor.searchGame': 'Busque um jogo na Steam, ou digite o nome e aperte Enter',
|
||||
'profile.editor.searching': 'Buscando…',
|
||||
'profile.editor.useTyped': 'Usar "{name}" como digitado',
|
||||
'profile.editor.gameNote': 'Por que esse? (opcional)',
|
||||
'profile.editor.gameTags': 'Tags separadas por vírgula (até 3)',
|
||||
'profile.editor.quote': 'Citação',
|
||||
'profile.editor.quote.placeholder': 'Uma frase que diga algo sobre você…',
|
||||
'profile.editor.quote.author': 'Quem disse (opcional)',
|
||||
'profile.editor.remove': 'Remover',
|
||||
'profile.editor.save': 'Salvar',
|
||||
'profile.editor.saving': 'Salvando...',
|
||||
'profile.editor.saved': 'Perfil atualizado',
|
||||
'profile.editor.saveFailed': 'Não foi possível salvar o perfil',
|
||||
'profile.editor.discard': 'Descartar',
|
||||
'profile.editor.resetAll': 'Zerar tudo',
|
||||
'profile.font.default': 'Padrão',
|
||||
'profile.font.display': 'Display',
|
||||
'profile.font.comic': 'Quadrinho',
|
||||
'profile.font.script': 'Manuscrita',
|
||||
'profile.font.pixel': 'Pixel',
|
||||
'profile.font.future': 'Futurista',
|
||||
'profile.font.serif': 'Serifa',
|
||||
'profile.font.mono': 'Mono',
|
||||
'profile.font.round': 'Redonda',
|
||||
'profile.font.marker': 'Marcador',
|
||||
'profile.effect.solid': 'Sólido',
|
||||
'profile.effect.gradient': 'Gradiente',
|
||||
'profile.effect.neon': 'Neon',
|
||||
'profile.effect.toon': 'Toon',
|
||||
'profile.effect.pop': 'Pop',
|
||||
'profile.effect.gummy': 'Gummy',
|
||||
'profile.effect.prism': 'Prisma',
|
||||
'profile.effect.glitch': 'Glitch',
|
||||
'profile.effect.fire': 'Fogo',
|
||||
'profile.decoration.none': 'Nenhuma',
|
||||
'profile.decoration.ring': 'Anel',
|
||||
'profile.decoration.neon': 'Neon',
|
||||
'profile.decoration.flames': 'Chamas',
|
||||
'profile.decoration.stars': 'Estrelas',
|
||||
'profile.decoration.halo': 'Auréola',
|
||||
'profile.decoration.vines': 'Folhas',
|
||||
'profile.decoration.pixel': 'Pixel',
|
||||
'profile.decoration.hearts': 'Corações',
|
||||
'profile.decoration.frost': 'Gelo',
|
||||
'profile.decoration.crown': 'Coroa',
|
||||
'profile.profileEffect.none': 'Nenhum',
|
||||
'profile.profileEffect.sparkles': 'Brilhos',
|
||||
'profile.profileEffect.rain': 'Chuva',
|
||||
'profile.profileEffect.snow': 'Neve',
|
||||
'profile.profileEffect.matrix': 'Matrix',
|
||||
'profile.profileEffect.confetti': 'Confete',
|
||||
'profile.profileEffect.aurora': 'Aurora',
|
||||
'profile.profileEffect.embers': 'Brasas',
|
||||
'profile.profileEffect.bubbles': 'Bolhas',
|
||||
'profile.profileEffect.hearts': 'Corações',
|
||||
'profile.nameplate.none': 'Nenhum',
|
||||
'profile.nameplate.sunset': 'Pôr do sol',
|
||||
'profile.nameplate.ocean': 'Oceano',
|
||||
'profile.nameplate.neon': 'Neon',
|
||||
'profile.nameplate.forest': 'Floresta',
|
||||
'profile.nameplate.royal': 'Real',
|
||||
'profile.nameplate.brasil': 'Brasil',
|
||||
'profile.nameplate.cyber': 'Cyber',
|
||||
'profile.nameplate.blossom': 'Flor',
|
||||
'profile.nameplate.midnight': 'Meia-noite',
|
||||
'profile.nameplate.custom': 'Personalizado',
|
||||
'profile.badge.admin': 'Admin da instância',
|
||||
'profile.badge.owner': 'Dono do servidor',
|
||||
'profile.badge.founder': 'Fundador — entrou no primeiro mês',
|
||||
'profile.badge.veteran': 'Veterano — mais de um ano de casa',
|
||||
'profile.badge.og': 'OG — mais de seis meses de casa',
|
||||
'profile.badge.stylist': 'Estiloso — perfil personalizado',
|
||||
'profile.widgets.collection': 'Coleção de jogos',
|
||||
'profile.widgets.favoriteGame': 'Jogo favorito',
|
||||
'profile.widgets.rotation': 'Na rotação',
|
||||
'profile.widgets.wishlist': 'Quero jogar',
|
||||
'profile.widgets.empty': 'Nada no quadro ainda.',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User, UserStatus } from '@backspace/shared';
|
||||
import type { User, UserStatus, UpdateUserRequest } from '@backspace/shared';
|
||||
import { api } from '../api/client';
|
||||
import { useChatStore } from './chatStore';
|
||||
import { useSpaceStore } from './spaceStore';
|
||||
@@ -20,7 +20,7 @@ interface AuthState {
|
||||
register: (username: string, password: string, displayName?: string, avatarColor?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loadUser: () => Promise<void>;
|
||||
updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; avatarColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
|
||||
updateProfile: (data: UpdateUserRequest) => Promise<void>;
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<FederationOpResult[]>;
|
||||
deleteAccount: (password: string, username: string) => Promise<void>;
|
||||
setUser: (user: User) => void;
|
||||
|
||||
@@ -14,6 +14,78 @@
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Profile name-style display fonts (see shared/profile.ts NAME_FONTS) ── */
|
||||
@font-face {
|
||||
font-family: 'Bangers';
|
||||
src: url('/fonts/Bangers-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cinzel';
|
||||
src: url('/fonts/Cinzel-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fredoka';
|
||||
src: url('/fonts/Fredoka-600.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Orbitron';
|
||||
src: url('/fonts/Orbitron-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Pacifico';
|
||||
src: url('/fonts/Pacifico-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Permanent Marker';
|
||||
src: url('/fonts/PermanentMarker-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
src: url('/fonts/PlayfairDisplay-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Press Start 2P';
|
||||
src: url('/fonts/PressStart2P-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rubik Mono One';
|
||||
src: url('/fonts/RubikMonoOne-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
src: url('/fonts/SpaceMono-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -561,3 +633,102 @@
|
||||
/* ── Electron window drag regions ── */
|
||||
.titlebar-drag { -webkit-app-region: drag; }
|
||||
.titlebar-no-drag { -webkit-app-region: no-drag; }
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Profile identity — name effects, avatar decorations, profile effects,
|
||||
nameplates. Colours arrive as CSS variables (--c1, --c2) set inline by the
|
||||
renderer; everything else is class-driven so the catalog in
|
||||
shared/profile.ts is the single list of ids.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Fonts */
|
||||
.name-font-default { font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
.name-font-display { font-family: 'Rubik Mono One', 'DM Sans', sans-serif; letter-spacing: -0.02em; }
|
||||
.name-font-comic { font-family: 'Bangers', 'DM Sans', sans-serif; letter-spacing: 0.04em; }
|
||||
.name-font-script { font-family: 'Pacifico', cursive; }
|
||||
.name-font-pixel { font-family: 'Press Start 2P', monospace; font-size: 0.78em; }
|
||||
.name-font-future { font-family: 'Orbitron', 'DM Sans', sans-serif; letter-spacing: 0.04em; }
|
||||
.name-font-serif { font-family: 'Playfair Display', Georgia, serif; }
|
||||
.name-font-mono { font-family: 'Space Mono', ui-monospace, monospace; }
|
||||
.name-font-round { font-family: 'Fredoka', 'DM Sans', sans-serif; }
|
||||
.name-font-marker { font-family: 'Permanent Marker', cursive; }
|
||||
|
||||
/* Effects — `.name-fx` carries the shared bits */
|
||||
.name-fx { display: inline-block; --c1: #efefef; --c2: #7c6cf6; }
|
||||
.name-fx-solid { color: var(--c1); }
|
||||
.name-fx-gradient,
|
||||
.name-fx-gummy,
|
||||
.name-fx-prism,
|
||||
.name-fx-fire {
|
||||
background-clip: text; -webkit-background-clip: text; color: transparent; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.name-fx-gradient { background-image: linear-gradient(90deg, var(--c1), var(--c2)); }
|
||||
.name-fx-neon { color: var(--c1); text-shadow: 0 0 4px var(--c1), 0 0 12px var(--c2), 0 0 26px var(--c2); }
|
||||
.name-fx-toon { color: var(--c1); -webkit-text-stroke: 1px rgba(0,0,0,0.85); text-shadow: 2px 2px 0 var(--c2), 3px 3px 0 rgba(0,0,0,0.5); }
|
||||
.name-fx-pop { color: var(--c1); text-shadow: 2px 2px 0 var(--c2), 4px 4px 0 rgba(0,0,0,0.45); }
|
||||
.name-fx-gummy { background-image: linear-gradient(180deg, color-mix(in srgb, var(--c1) 70%, #fff), var(--c1) 45%, var(--c2)); filter: drop-shadow(0 1px 0 rgba(255,255,255,0.35)) drop-shadow(0 2px 2px rgba(0,0,0,0.5)); }
|
||||
.name-fx-prism { background-image: linear-gradient(90deg, var(--c1), var(--c2), #ffffff, var(--c1)); background-size: 300% 100%; animation: name-prism 4s linear infinite; }
|
||||
.name-fx-fire { background-image: linear-gradient(0deg, var(--c1) 0%, var(--c2) 55%, #fff8e1 100%); animation: name-fire 0.9s ease-in-out infinite alternate; filter: drop-shadow(0 0 6px var(--c1)); }
|
||||
.name-fx-glitch { color: var(--c1); position: relative; animation: name-glitch 2.6s steps(1) infinite; }
|
||||
|
||||
@keyframes name-prism { to { background-position: 300% 0; } }
|
||||
@keyframes name-fire { from { filter: drop-shadow(0 0 4px var(--c1)) brightness(1); } to { filter: drop-shadow(0 0 10px var(--c2)) brightness(1.15); } }
|
||||
@keyframes name-glitch {
|
||||
0%, 84%, 100% { text-shadow: none; transform: none; }
|
||||
86% { text-shadow: -2px 0 #22d3ee, 2px 0 var(--c2); transform: translateX(1px); }
|
||||
88% { text-shadow: 2px 0 #22d3ee, -2px 0 var(--c2); transform: translateX(-1px) skewX(4deg); }
|
||||
90% { text-shadow: -1px 0 #22d3ee, 1px 0 var(--c2); transform: none; }
|
||||
92% { text-shadow: none; }
|
||||
95% { text-shadow: 3px 0 var(--c2), -3px 0 #22d3ee; transform: translateY(-1px); }
|
||||
97% { text-shadow: none; transform: none; }
|
||||
}
|
||||
|
||||
/* Avatar decorations — an absolutely positioned layer over the avatar */
|
||||
.avatar-deco { position: absolute; inset: -14%; pointer-events: none; }
|
||||
.avatar-deco svg { width: 100%; height: 100%; overflow: visible; }
|
||||
.deco-spin { animation: deco-spin 9s linear infinite; }
|
||||
.deco-spin-slow { animation: deco-spin 22s linear infinite; }
|
||||
.deco-pulse { animation: deco-pulse 2.2s ease-in-out infinite; }
|
||||
.deco-flicker { animation: deco-flicker 0.35s ease-in-out infinite alternate; }
|
||||
.deco-float { animation: deco-float 3s ease-in-out infinite; }
|
||||
@keyframes deco-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes deco-pulse { 0%,100% { opacity: 0.75; transform: scale(1); } 50% { opacity: 1; transform: scale(1.04); } }
|
||||
@keyframes deco-flicker { from { transform: scaleY(1) translateY(0); opacity: 0.9; } to { transform: scaleY(1.08) translateY(-2%); opacity: 1; } }
|
||||
@keyframes deco-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6%); } }
|
||||
|
||||
/* Profile effects — particle layers over the banner */
|
||||
.profile-fx { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
|
||||
.profile-fx i { position: absolute; display: block; will-change: transform, opacity; }
|
||||
.fx-sparkles i { width: 6px; height: 6px; background: #fff; border-radius: 50%; box-shadow: 0 0 6px 2px rgba(255,255,255,0.8); animation: fx-twinkle var(--d, 2s) ease-in-out var(--t, 0s) infinite; }
|
||||
.fx-rain i { width: 1.5px; height: 18px; background: linear-gradient(180deg, transparent, rgba(180,200,255,0.8)); animation: fx-fall var(--d, 1s) linear var(--t, 0s) infinite; }
|
||||
.fx-snow i { width: 5px; height: 5px; background: #fff; border-radius: 50%; opacity: 0.9; animation: fx-drift var(--d, 5s) linear var(--t, 0s) infinite; }
|
||||
.fx-matrix i { width: 10px; font: 700 10px 'Space Mono', monospace; color: #4ade80; text-shadow: 0 0 6px #22c55e; line-height: 1; animation: fx-fall var(--d, 3s) linear var(--t, 0s) infinite; }
|
||||
.fx-confetti i { width: 7px; height: 11px; background: var(--k, #f472b6); border-radius: 1px; animation: fx-confetti var(--d, 3s) ease-in var(--t, 0s) infinite; }
|
||||
.fx-embers i { width: 4px; height: 4px; border-radius: 50%; background: #fb923c; box-shadow: 0 0 6px 1px #f97316; animation: fx-rise var(--d, 3s) ease-out var(--t, 0s) infinite; }
|
||||
.fx-bubbles i { width: var(--s, 10px); height: var(--s, 10px); border-radius: 50%; border: 1.5px solid rgba(255,255,255,0.7); background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.55), transparent 60%); animation: fx-rise var(--d, 5s) ease-in-out var(--t, 0s) infinite; }
|
||||
.fx-hearts i { font-size: 12px; line-height: 1; animation: fx-rise var(--d, 4s) ease-out var(--t, 0s) infinite; }
|
||||
.fx-aurora::before, .fx-aurora::after {
|
||||
content: ''; position: absolute; inset: -40% -20%; border-radius: 50%; filter: blur(22px); opacity: 0.55; mix-blend-mode: screen;
|
||||
background: linear-gradient(90deg, #22d3ee, #a78bfa, #34d399, #f472b6); background-size: 300% 100%;
|
||||
animation: fx-aurora 9s ease-in-out infinite alternate;
|
||||
}
|
||||
.fx-aurora::after { animation-duration: 13s; animation-direction: alternate-reverse; opacity: 0.35; }
|
||||
@keyframes fx-twinkle { 0%,100% { opacity: 0; transform: scale(0.4); } 50% { opacity: 1; transform: scale(1); } }
|
||||
@keyframes fx-fall { from { transform: translateY(-30px); opacity: 0; } 10% { opacity: 1; } to { transform: translateY(140px); opacity: 0; } }
|
||||
@keyframes fx-drift { from { transform: translate(0, -10px); } to { transform: translate(14px, 130px); } }
|
||||
@keyframes fx-confetti { from { transform: translateY(-20px) rotate(0); opacity: 1; } to { transform: translateY(130px) rotate(540deg); opacity: 0.2; } }
|
||||
@keyframes fx-rise { from { transform: translateY(20px) scale(0.6); opacity: 0; } 20% { opacity: 1; } to { transform: translateY(-120px) scale(1.1); opacity: 0; } }
|
||||
@keyframes fx-aurora { from { background-position: 0% 50%; transform: translateX(-6%) rotate(-4deg); } to { background-position: 100% 50%; transform: translateX(6%) rotate(4deg); } }
|
||||
|
||||
/* Nameplates — member-row background, fading out toward the right */
|
||||
.nameplate { --np1: #7c6cf6; --np2: #f472b6; background: linear-gradient(90deg, color-mix(in srgb, var(--np1) 55%, transparent), color-mix(in srgb, var(--np2) 30%, transparent) 60%, transparent); }
|
||||
.nameplate-sunset { --np1: #f97316; --np2: #ec4899; }
|
||||
.nameplate-ocean { --np1: #0ea5e9; --np2: #6366f1; }
|
||||
.nameplate-neon { --np1: #22d3ee; --np2: #d946ef; }
|
||||
.nameplate-forest { --np1: #16a34a; --np2: #84cc16; }
|
||||
.nameplate-royal { --np1: #7c3aed; --np2: #f59e0b; }
|
||||
.nameplate-brasil { background: linear-gradient(90deg, rgba(22,163,74,0.6), rgba(250,204,21,0.55) 50%, rgba(37,99,235,0.6) 75%, transparent); }
|
||||
.nameplate-cyber { background: repeating-linear-gradient(135deg, rgba(34,211,238,0.35) 0 6px, rgba(217,70,239,0.3) 6px 12px), linear-gradient(90deg, rgba(0,0,0,0.3), transparent); }
|
||||
.nameplate-blossom { --np1: #fb7185; --np2: #f9a8d4; }
|
||||
.nameplate-midnight { --np1: #1e1b4b; --np2: #312e81; }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS } from '@backspace/shared/src/profile';
|
||||
import { computeBadges, detectLinkIcon } from './profileStyle';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
describe('normalizeProfileStyle', () => {
|
||||
it('returns defaults for garbage', () => {
|
||||
expect(normalizeProfileStyle(null)).toEqual(DEFAULT_PROFILE_STYLE);
|
||||
expect(normalizeProfileStyle('nope')).toEqual(DEFAULT_PROFILE_STYLE);
|
||||
expect(normalizeProfileStyle({ name: 'x', links: 'y', widgets: 3 })).toEqual(DEFAULT_PROFILE_STYLE);
|
||||
});
|
||||
|
||||
it('falls back on unknown catalog ids instead of throwing', () => {
|
||||
const s = normalizeProfileStyle({ name: { font: 'wingdings', effect: 'rainbow' }, decoration: 'lasers', effect: 'volcano', nameplate: 'zebra' });
|
||||
expect(s.name.font).toBe('default');
|
||||
expect(s.name.effect).toBe('solid');
|
||||
expect(s.decoration).toBe('none');
|
||||
expect(s.effect).toBe('none');
|
||||
expect(s.nameplate).toBe('none');
|
||||
});
|
||||
|
||||
it('rejects bad colours, lowercases good ones', () => {
|
||||
const s = normalizeProfileStyle({ name: { colors: ['#ABCDEF', 'red'] } });
|
||||
expect(s.name.colors).toEqual(['#abcdef', DEFAULT_PROFILE_STYLE.name.colors[1]]);
|
||||
});
|
||||
|
||||
it('bounds every string', () => {
|
||||
const s = normalizeProfileStyle({ pronouns: 'x'.repeat(500), widgets: { quote: { text: 'q'.repeat(999), author: 'a'.repeat(999) } } });
|
||||
expect(s.pronouns).toHaveLength(PROFILE_LIMITS.pronouns);
|
||||
expect(s.widgets.quote?.text).toHaveLength(PROFILE_LIMITS.quoteText);
|
||||
expect(s.widgets.quote?.author).toHaveLength(PROFILE_LIMITS.quoteAuthor);
|
||||
});
|
||||
|
||||
it('tag: uppercase alphanumerics, 2–4 chars, one emoji grapheme', () => {
|
||||
expect(normalizeProfileStyle({ tag: { text: 'strt!', emoji: '🔥🔥', color: '#ff0000' } }).tag).toEqual({ text: 'STRT', emoji: '🔥', color: '#ff0000' });
|
||||
expect(normalizeProfileStyle({ tag: { text: 'a' } }).tag).toBeNull();
|
||||
expect(normalizeProfileStyle({ tag: { text: 'abcdefg' } }).tag?.text).toBe('ABCD');
|
||||
});
|
||||
|
||||
it('links: https only, capped, label defaults to host', () => {
|
||||
const s = normalizeProfileStyle({ links: [
|
||||
{ label: '', url: 'https://www.twitch.tv/syncwrld' },
|
||||
{ label: 'bad', url: 'http://insecure.example' },
|
||||
{ label: 'js', url: 'javascript:alert(1)' },
|
||||
...Array.from({ length: 10 }, (_, i) => ({ label: `l${i}`, url: `https://x.com/${i}` })),
|
||||
] });
|
||||
expect(s.links).toHaveLength(PROFILE_LIMITS.links);
|
||||
expect(s.links[0]).toEqual({ label: 'twitch.tv', url: 'https://www.twitch.tv/syncwrld' });
|
||||
expect(s.links.some((l) => l.url.startsWith('http:') || l.url.startsWith('javascript'))).toBe(false);
|
||||
});
|
||||
|
||||
it('game covers: only Steam CDN hosts survive', () => {
|
||||
const s = normalizeProfileStyle({ widgets: {
|
||||
favoriteGame: { name: 'CS2', appId: 730, cover: 'https://cdn.cloudflare.steamstatic.com/steam/apps/730/library_600x900.jpg' },
|
||||
rotation: [{ name: 'Sneaky', cover: 'https://evil.example/track.png' }],
|
||||
} });
|
||||
expect(s.widgets.favoriteGame?.cover).toContain('steamstatic');
|
||||
expect(s.widgets.rotation[0]?.cover).toBeNull();
|
||||
expect(s.widgets.rotation[0]?.name).toBe('Sneaky');
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const once = normalizeProfileStyle({ name: { font: 'comic', effect: 'neon', colors: ['#111111', '#222222'] }, pronouns: 'ele/dele' });
|
||||
expect(normalizeProfileStyle(once)).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLinkIcon', () => {
|
||||
it('maps known hosts and subdomains', () => {
|
||||
expect(detectLinkIcon('https://www.twitch.tv/x')).toBe('twitch');
|
||||
expect(detectLinkIcon('https://youtu.be/abc')).toBe('youtube');
|
||||
expect(detectLinkIcon('https://open.spotify.com/user/x')).toBe('spotify');
|
||||
expect(detectLinkIcon('https://steamcommunity.com/id/x')).toBe('steam');
|
||||
expect(detectLinkIcon('https://twitter.com/x')).toBe('x');
|
||||
expect(detectLinkIcon('https://streethosting.com.br')).toBe('link');
|
||||
expect(detectLinkIcon('not a url')).toBe('link');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeBadges', () => {
|
||||
const now = Date.UTC(2026, 8, 2);
|
||||
const DAY = 86_400_000;
|
||||
const base = { id: 'u1', username: 'u', displayName: null, avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, homeInstance: null, homeUserId: null, replicatedInstances: [] } as unknown as User;
|
||||
|
||||
it('derives owner, founder, tenure and stylist from facts already present', () => {
|
||||
const space = { ownerId: 'u1', createdAt: now - 400 * DAY };
|
||||
const owner = computeBadges({ ...base, createdAt: now - 400 * DAY }, { space, now });
|
||||
expect(owner).toEqual(['owner', 'veteran']);
|
||||
|
||||
const founder = computeBadges({ ...base, id: 'u2', createdAt: now - 200 * DAY }, { space, member: { joinedAt: space.createdAt + 5 * DAY }, now });
|
||||
expect(founder).toEqual(['founder', 'og']);
|
||||
|
||||
const late = computeBadges({ ...base, id: 'u3', createdAt: now - 10 * DAY }, { space, member: { joinedAt: space.createdAt + 90 * DAY }, now });
|
||||
expect(late).toEqual([]);
|
||||
|
||||
const styled = computeBadges({ ...base, id: 'u4', createdAt: now, profileStyle: normalizeProfileStyle({ decoration: 'flames' }) }, { now });
|
||||
expect(styled).toEqual(['stylist']);
|
||||
});
|
||||
|
||||
it('admin comes first', () => {
|
||||
expect(computeBadges({ ...base, isAdmin: true, createdAt: now }, { now })).toEqual(['admin']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { User, MemberWithUser, Space } from '@backspace/shared';
|
||||
import {
|
||||
DEFAULT_PROFILE_STYLE,
|
||||
normalizeProfileStyle,
|
||||
type ProfileStyle,
|
||||
type NameStyle,
|
||||
type Nameplate,
|
||||
} from '@backspace/shared/src/profile';
|
||||
|
||||
/** The user's style, always complete — defaults fill whatever is missing. */
|
||||
export function getProfileStyle(user: Pick<User, 'profileStyle'> | null | undefined): ProfileStyle {
|
||||
if (!user?.profileStyle) return DEFAULT_PROFILE_STYLE;
|
||||
return normalizeProfileStyle(user.profileStyle);
|
||||
}
|
||||
|
||||
/** Class + inline vars for a styled display name. */
|
||||
export function nameStyleProps(name: NameStyle): { className: string; style: CSSProperties } {
|
||||
return {
|
||||
className: `name-fx name-fx-${name.effect} name-font-${name.font}`,
|
||||
style: { '--c1': name.colors[0], '--c2': name.colors[1] } as CSSProperties,
|
||||
};
|
||||
}
|
||||
|
||||
/** Class + vars for a member-row nameplate; null when the row is plain. */
|
||||
export function nameplateProps(
|
||||
nameplate: Nameplate,
|
||||
custom: [string, string],
|
||||
): { className: string; style: CSSProperties } | null {
|
||||
if (nameplate === 'none') return null;
|
||||
if (nameplate === 'custom') {
|
||||
return { className: 'nameplate', style: { '--np1': custom[0], '--np2': custom[1] } as CSSProperties };
|
||||
}
|
||||
return { className: `nameplate nameplate-${nameplate}`, style: {} };
|
||||
}
|
||||
|
||||
/** Theme gradient for the card ground / banner fallback. */
|
||||
export function themeGradient(style: ProfileStyle): string | null {
|
||||
if (!style.theme) return null;
|
||||
const [a, b] = style.theme.colors;
|
||||
return `linear-gradient(135deg, ${a}, ${b})`;
|
||||
}
|
||||
|
||||
export type LinkIconId =
|
||||
| 'steam' | 'twitch' | 'youtube' | 'instagram' | 'github' | 'x' | 'tiktok'
|
||||
| 'spotify' | 'discord' | 'reddit' | 'kick' | 'link';
|
||||
|
||||
/** Picks a brand icon from the URL's host; generic link otherwise. */
|
||||
export function detectLinkIcon(url: string): LinkIconId {
|
||||
let host = '';
|
||||
try { host = new URL(url).hostname.replace(/^www\./, '').toLowerCase(); } catch { return 'link'; }
|
||||
const table: [string, LinkIconId][] = [
|
||||
['steamcommunity.com', 'steam'], ['steampowered.com', 'steam'],
|
||||
['twitch.tv', 'twitch'], ['youtube.com', 'youtube'], ['youtu.be', 'youtube'],
|
||||
['instagram.com', 'instagram'], ['github.com', 'github'],
|
||||
['x.com', 'x'], ['twitter.com', 'x'], ['tiktok.com', 'tiktok'],
|
||||
['spotify.com', 'spotify'], ['discord.gg', 'discord'], ['discord.com', 'discord'],
|
||||
['reddit.com', 'reddit'], ['kick.com', 'kick'],
|
||||
];
|
||||
for (const [h, id] of table) if (host === h || host.endsWith('.' + h)) return id;
|
||||
return 'link';
|
||||
}
|
||||
|
||||
export type BadgeId = 'admin' | 'owner' | 'founder' | 'veteran' | 'og' | 'stylist';
|
||||
|
||||
export interface BadgeContext {
|
||||
/** Space the profile is being viewed in, if any. */
|
||||
space?: Pick<Space, 'ownerId' | 'createdAt'> | null;
|
||||
member?: Pick<MemberWithUser, 'joinedAt'> | null;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Badges are derived, not stored: every one follows from facts the client
|
||||
* already has, so there is nothing to grant, revoke or migrate.
|
||||
*/
|
||||
export function computeBadges(user: User, ctx: BadgeContext = {}): BadgeId[] {
|
||||
const now = ctx.now ?? Date.now();
|
||||
const out: BadgeId[] = [];
|
||||
if (user.isAdmin) out.push('admin');
|
||||
if (ctx.space && ctx.space.ownerId === user.id) out.push('owner');
|
||||
// Joined within the space's first month — an early member of that resenha.
|
||||
if (ctx.space && ctx.member && ctx.member.joinedAt - ctx.space.createdAt < 30 * DAY && ctx.space.ownerId !== user.id) {
|
||||
out.push('founder');
|
||||
}
|
||||
const age = now - user.createdAt;
|
||||
if (age >= 365 * DAY) out.push('veteran');
|
||||
else if (age >= 180 * DAY) out.push('og');
|
||||
const s = getProfileStyle(user);
|
||||
if (s.name.effect !== 'solid' || s.decoration !== 'none' || s.effect !== 'none') out.push('stylist');
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user