Profile pictures, display name default, coloured service names
CI / check (push) Canceled after 0s

- Upload a profile picture on web and mobile. The API square-crops it to
  512px WebP, strips metadata (phone photos carry GPS), stores it in
  Postgres and serves it with immutable caching. Avatar URLs are stored as
  paths, so they survive moving the API. Arbitrary avatar URLs are no
  longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
  accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-05 23:38:00 -04:00
co-authored by Claude Opus 5.5
parent 9873cc1710
commit 2b02aa8e89
25 changed files with 1746 additions and 22 deletions
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE "avatars" (
"user_id" uuid PRIMARY KEY NOT NULL,
"image" "bytea" NOT NULL,
"content_type" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "avatars" ADD CONSTRAINT "avatars_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
UPDATE "users" SET "display_name" = "username" WHERE "display_name" IS NULL OR "display_name" = '';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1791240840729, "when": 1791240840729,
"tag": "0009_black_wild_pack", "tag": "0009_black_wild_pack",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1791257078479,
"tag": "0010_avatars",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -21,6 +21,7 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
"@fastify/multipart": "^10.1.2",
"@fastify/rate-limit": "^11.2.0", "@fastify/rate-limit": "^11.2.0",
"@fastify/websocket": "^11.3.2", "@fastify/websocket": "^11.3.2",
"@musicshare/shared": "*", "@musicshare/shared": "*",
@@ -31,6 +32,7 @@
"fastify": "^5.12.5", "fastify": "^5.12.5",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sharp": "^0.35.5",
"undici": "^6.29.0", "undici": "^6.29.0",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
+4
View File
@@ -1,6 +1,7 @@
import cookie from '@fastify/cookie'; import cookie from '@fastify/cookie';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit'; import rateLimit from '@fastify/rate-limit';
import multipart from '@fastify/multipart';
import websocket from '@fastify/websocket'; import websocket from '@fastify/websocket';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
@@ -11,6 +12,7 @@ import { HttpError } from './lib/http';
import { redis } from './lib/redis'; import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin'; import { adminRoutes } from './routes/admin';
import { authRoutes } from './routes/auth'; import { authRoutes } from './routes/auth';
import { avatarRoutes } from './routes/avatars';
import { connectionRoutes } from './routes/connections'; import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices'; import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me'; import { meRoutes } from './routes/me';
@@ -41,6 +43,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
// Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances. // Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances.
if (rateLimits) await app.register(rateLimit, { global: false, redis }); if (rateLimits) await app.register(rateLimit, { global: false, redis });
await app.register(websocket); await app.register(websocket);
await app.register(multipart);
app.decorateRequest('user', null); app.decorateRequest('user', null);
app.decorateRequest('sessionId', null); app.decorateRequest('sessionId', null);
@@ -64,6 +67,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(authRoutes); await app.register(authRoutes);
await app.register(meRoutes); await app.register(meRoutes);
await app.register(avatarRoutes);
await app.register(userRoutes); await app.register(userRoutes);
await app.register(safetyRoutes); await app.register(safetyRoutes);
await app.register(adminRoutes); await app.register(adminRoutes);
+14
View File
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { import {
boolean, boolean,
customType,
index, index,
integer, integer,
jsonb, jsonb,
@@ -23,6 +24,7 @@ export const users = pgTable('users', {
passwordHash: text('password_hash').notNull(), passwordHash: text('password_hash').notNull(),
displayName: varchar('display_name', { length: 64 }), displayName: varchar('display_name', { length: 64 }),
bio: varchar('bio', { length: 280 }), bio: varchar('bio', { length: 280 }),
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
avatarUrl: varchar('avatar_url', { length: 500 }), avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(), preferredService: text('preferred_service').$type<StreamingService>(),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ /** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
@@ -236,6 +238,18 @@ export const featuredSongs = pgTable('featured_songs', {
.default(sql`now()`), .default(sql`now()`),
}); });
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */
export const avatars = pgTable('avatars', {
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
image: bytea('image').notNull(),
contentType: text('content_type').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
export type Connection = typeof connections.$inferSelect; export type Connection = typeof connections.$inferSelect;
export type Track = typeof tracks.$inferSelect; export type Track = typeof tracks.$inferSelect;
+29
View File
@@ -0,0 +1,29 @@
import sharp from 'sharp';
import { env } from '../env';
import { HttpError } from './http';
export const AVATAR_SIZE = 512;
export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
/**
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
* working if the API moves; older rows may hold a full external URL.
*/
export const publicAvatarUrl = (stored: string | null): string | null =>
stored?.startsWith('/') ? env.API_PUBLIC_URL.replace(/\/$/, '') + stored : stored;
/**
* Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF
* (phone photos carry GPS location) and anything that isn't image data.
*/
export async function processAvatar(input: Buffer): Promise<Buffer> {
try {
return await sharp(input, { limitInputPixels: 50_000_000, animated: false })
.rotate() // apply the EXIF orientation before it's stripped
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
.webp({ quality: 82 })
.toBuffer();
} catch {
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
}
}
+4 -1
View File
@@ -12,6 +12,9 @@ const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' }
export async function authRoutes(app: FastifyInstance) { export async function authRoutes(app: FastifyInstance) {
app.post('/auth/register', authRateLimit, async (req, reply) => { app.post('/auth/register', authRateLimit, async (req, reply) => {
const input = registerSchema.parse(req.body); const input = registerSchema.parse(req.body);
// Usernames are stored lowercase; keep the capitalization they typed as the display name.
const typed = String((req.body as { username?: unknown }).username ?? '').trim();
const displayName = typed.toLowerCase() === input.username ? typed : input.username;
const [taken] = await db const [taken] = await db
.select({ username: schema.users.username, email: schema.users.email }) .select({ username: schema.users.username, email: schema.users.email })
.from(schema.users) .from(schema.users)
@@ -25,7 +28,7 @@ export async function authRoutes(app: FastifyInstance) {
const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }]; const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }];
const [created] = await tx const [created] = await tx
.insert(schema.users) .insert(schema.users)
.values({ username: input.username, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' }) .values({ username: input.username, displayName, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' })
.returning(); .returning();
return created; return created;
}); });
+56
View File
@@ -0,0 +1,56 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { AVATAR_MAX_UPLOAD_BYTES, processAvatar } from '../lib/avatars';
import { HttpError } from '../lib/http';
import { toMeDTO } from './me';
export async function avatarRoutes(app: FastifyInstance) {
/** Multipart upload with one file field. Returns the updated profile. */
app.post('/me/avatar', { config: { rateLimit: { max: 20, timeWindow: '1 hour' } } }, async (req) => {
const user = requireUser(req);
const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined);
if (!file) throw new HttpError(400, 'Choose a picture to upload');
const input = await file.toBuffer().catch(() => {
throw new HttpError(413, 'That picture is too big (10 MB max)');
});
const image = await processAvatar(input);
const values = { image, contentType: 'image/webp', updatedAt: new Date() };
await db
.insert(schema.avatars)
.values({ userId: user.id, ...values })
.onConflictDoUpdate({ target: schema.avatars.userId, set: values });
// The version busts caches, since the image itself is served as immutable.
const [updated] = await db
.update(schema.users)
.set({ avatarUrl: `/avatars/${user.id}?v=${values.updatedAt.getTime()}` })
.where(eq(schema.users.id, user.id))
.returning();
return toMeDTO(updated!);
});
app.delete('/me/avatar', async (req) => {
const user = requireUser(req);
await db.delete(schema.avatars).where(eq(schema.avatars.userId, user.id));
const [updated] = await db.update(schema.users).set({ avatarUrl: null }).where(eq(schema.users.id, user.id)).returning();
return toMeDTO(updated!);
});
app.get('/avatars/:userId', async (req, reply) => {
const { userId } = z.object({ userId: z.uuid() }).parse(req.params);
const [row] = await db
.select({ image: schema.avatars.image, contentType: schema.avatars.contentType })
.from(schema.avatars)
.where(eq(schema.avatars.userId, userId));
if (!row) throw new HttpError(404, 'No picture');
return reply
.header('Content-Type', row.contentType)
.header('Cache-Control', 'public, max-age=31536000, immutable')
.header('Content-Security-Policy', "default-src 'none'")
.header('Cross-Origin-Resource-Policy', 'cross-origin')
.send(row.image);
});
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from
import { db, schema } from '../db'; import { db, schema } from '../db';
import type { User } from '../db/schema'; import type { User } from '../db/schema';
import { SESSION_COOKIE, requireUser } from '../lib/auth'; import { SESSION_COOKIE, requireUser } from '../lib/auth';
import { publicAvatarUrl } from '../lib/avatars';
import { effectivePlan, entitlementsFor } from '../lib/entitlements'; import { effectivePlan, entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http'; import { HttpError } from '../lib/http';
import { deezerTrack } from '../providers/catalog'; import { deezerTrack } from '../providers/catalog';
@@ -17,7 +18,7 @@ export const toMeDTO = (u: User): MeDTO => ({
id: u.id, id: u.id,
username: u.username, username: u.username,
displayName: u.displayName, displayName: u.displayName,
avatarUrl: u.avatarUrl, avatarUrl: publicAvatarUrl(u.avatarUrl),
email: u.email, email: u.email,
bio: u.bio, bio: u.bio,
preferredService: u.preferredService, preferredService: u.preferredService,
@@ -34,6 +35,8 @@ export async function meRoutes(app: FastifyInstance) {
app.patch('/me', async (req) => { app.patch('/me', async (req) => {
const user = requireUser(req); const user = requireUser(req);
const { onboarded, ...input } = updateMeSchema.parse(req.body); const { onboarded, ...input } = updateMeSchema.parse(req.body);
// Clearing the display name falls back to the username.
if ('displayName' in input && !input.displayName) input.displayName = user.username;
const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) }; const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) };
if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update
const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning(); const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning();
+2 -1
View File
@@ -2,6 +2,7 @@ import { and, desc, eq, inArray, lt } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared'; import type { NowPlayingDTO, NowPlayingProvider, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db'; import { db, schema } from '../db';
import type { User } from '../db/schema'; import type { User } from '../db/schema';
import { publicAvatarUrl } from '../lib/avatars';
import { nowPlayingChannel, redis } from '../lib/redis'; import { nowPlayingChannel, redis } from '../lib/redis';
import type { PollResult } from '../providers/types'; import type { PollResult } from '../providers/types';
import { enqueueResolve } from './queues'; import { enqueueResolve } from './queues';
@@ -11,7 +12,7 @@ export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' |
id: u.id, id: u.id,
username: u.username, username: u.username,
displayName: u.displayName, displayName: u.displayName,
avatarUrl: u.avatarUrl, avatarUrl: publicAvatarUrl(u.avatarUrl),
}); });
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */ /** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
+81
View File
@@ -0,0 +1,81 @@
import sharp from 'sharp';
import { describe, expect, it } from 'vitest';
import { bearer, createUser, useTestApp } from './helpers';
const app = useTestApp();
/** Builds a multipart body by hand, the way a browser or phone would send it. */
function multipart(file: Buffer, type = 'image/jpeg', name = 'photo.jpg') {
const boundary = '----musicshare-test';
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`),
file,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return { payload, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
}
const photo = () =>
sharp({ create: { width: 1200, height: 800, channels: 3, background: '#7b2ff7' } })
.jpeg()
.withExif({ IFD0: { Make: 'TestPhone', Model: 'GPS-Leaker' } })
.toBuffer();
describe('avatars', () => {
it('resizes, re-encodes and serves an uploaded picture', async () => {
const { token, user } = await createUser(app(), 'alice');
const { payload, headers } = multipart(await photo());
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
expect(res.statusCode).toBe(200);
const url: string = res.json().avatarUrl;
expect(url).toMatch(new RegExp(`^https://api\\.test/avatars/${user.id}\\?v=\\d+$`));
const img = await app().inject({ url: url.replace('https://api.test', '') });
expect(img.statusCode).toBe(200);
expect(img.headers['content-type']).toBe('image/webp');
expect(img.headers['cache-control']).toContain('immutable');
const meta = await sharp(img.rawPayload).metadata();
expect(meta).toMatchObject({ format: 'webp', width: 512, height: 512 });
expect(meta.exif).toBeUndefined(); // camera/GPS metadata stripped
// Shows up wherever the user does, e.g. their profile.
const profile = await app().inject({ url: '/users/alice', headers: bearer(token) });
expect(profile.json().user.avatarUrl).toBe(url);
});
it('rejects files that are not images', async () => {
const { token } = await createUser(app(), 'alice');
const { payload, headers } = multipart(Buffer.from('<script>alert(1)</script>'), 'image/png', 'x.png');
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/isn't an image/);
});
it('removes the picture', async () => {
const { token, user } = await createUser(app(), 'alice');
const { payload, headers } = multipart(await photo());
await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
const res = await app().inject({ method: 'DELETE', url: '/me/avatar', headers: bearer(token) });
expect(res.json().avatarUrl).toBeNull();
expect((await app().inject({ url: `/avatars/${user.id}` })).statusCode).toBe(404);
});
it('requires sign-in to upload', async () => {
const { payload, headers } = multipart(await photo());
expect((await app().inject({ method: 'POST', url: '/me/avatar', payload, headers })).statusCode).toBe(401);
});
});
describe('display name', () => {
it('defaults to the username as typed, and falls back to it when cleared', async () => {
const res = await app().inject({
method: 'POST',
url: '/auth/register',
payload: { username: 'BinaryGnome', email: 'g@example.com', password: 'password123' },
});
expect(res.json().user).toMatchObject({ username: 'binarygnome', displayName: 'BinaryGnome' });
const cleared = await app().inject({ method: 'PATCH', url: '/me', headers: bearer(res.json().token), payload: { displayName: '' } });
expect(cleared.json().displayName).toBe('binarygnome');
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ function assertTestTargets() {
export async function resetState() { export async function resetState() {
assertTestTargets(); assertTestTargets();
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports restart identity cascade`; await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars restart identity cascade`;
await redis.flushdb(); await redis.flushdb();
} }
+8
View File
@@ -42,6 +42,14 @@
"microphonePermission": false, "microphonePermission": false,
"recordAudioAndroid": false "recordAudioAndroid": false
} }
],
[
"expo-image-picker",
{
"photosPermission": "musicshare lets you choose a profile picture from your photos.",
"cameraPermission": "musicshare uses the camera to scan friends' QR codes.",
"microphonePermission": false
}
] ]
], ],
"experiments": { "experiments": {
+1
View File
@@ -15,6 +15,7 @@
"expo-glass-effect": "~57.0.4", "expo-glass-effect": "~57.0.4",
"expo-haptics": "~57.0.3", "expo-haptics": "~57.0.3",
"expo-image": "~57.0.5", "expo-image": "~57.0.5",
"expo-image-picker": "~57.0.20",
"expo-linear-gradient": "~57.0.2", "expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11", "expo-linking": "~57.0.11",
"expo-router": "~57.0.24", "expo-router": "~57.0.24",
+61 -8
View File
@@ -1,17 +1,21 @@
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { Alert, Platform, StyleSheet, Text, View } from 'react-native'; import { Alert, Platform, StyleSheet, View } from 'react-native';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
SERVICE_LABELS, SERVICE_LABELS,
STREAMING_SERVICES, STREAMING_SERVICES,
timeAgo, timeAgo,
type ConnectionDTO, type ConnectionDTO,
type MeDTO,
type NowPlayingProvider, type NowPlayingProvider,
} from '@musicshare/shared'; } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { RelinkReminder } from '@/components/RelinkReminder'; import { RelinkReminder } from '@/components/RelinkReminder';
import { FeaturedPicker } from '@/components/FeaturedPicker'; import { FeaturedPicker } from '@/components/FeaturedPicker';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls'; import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui'; import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -57,6 +61,7 @@ function ProfileSection() {
return ( return (
<Card> <Card>
<Heading>Profile</Heading> <Heading>Profile</Heading>
<AvatarPicker />
<Field label="Display name" value={displayName} onChangeText={setDisplayName} maxLength={64} /> <Field label="Display name" value={displayName} onChangeText={setDisplayName} maxLength={64} />
<Field label="Bio" value={bio} onChangeText={setBio} maxLength={280} multiline style={{ minHeight: 64 }} /> <Field label="Bio" value={bio} onChangeText={setBio} maxLength={280} multiline style={{ minHeight: 64 }} />
<Button title="Save" variant="primary" loading={busy} onPress={save} style={{ alignSelf: 'flex-start' }} /> <Button title="Save" variant="primary" loading={busy} onPress={save} style={{ alignSelf: 'flex-start' }} />
@@ -64,6 +69,54 @@ function ProfileSection() {
); );
} }
function AvatarPicker() {
const { me, setMe } = useSession();
const [busy, setBusy] = useState(false);
if (!me) return null;
const run = async (fn: () => Promise<MeDTO>) => {
setBusy(true);
try {
setMe(await fn());
} catch (err) {
showError(err, "Couldn't update your picture");
}
setBusy(false);
};
const pick = async () => {
// The system photo picker needs no permission on Android 13+; iOS asks the first time.
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.85,
});
const asset = result.canceled ? null : result.assets[0];
if (!asset) return;
const form = new FormData();
// React Native's FormData takes a file as { uri, name, type }.
form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob);
run(() => api.uploadAvatar(form));
};
const remove = () =>
Alert.alert('Remove your picture?', undefined, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => run(() => api.removeAvatar()) },
]);
return (
<View style={styles.avatarRow}>
<Avatar user={me} size={72} />
<View style={{ gap: space.sm }}>
<Button small variant="primary" loading={busy} title={me.avatarUrl ? 'Change picture' : 'Add a picture'} onPress={pick} />
{me.avatarUrl && !busy && <Button small title="Remove" onPress={remove} />}
</View>
</View>
);
}
function PreferredService() { function PreferredService() {
const { me, setMe } = useSession(); const { me, setMe } = useSession();
const choose = async (service: (typeof STREAMING_SERVICES)[number]) => { const choose = async (service: (typeof STREAMING_SERVICES)[number]) => {
@@ -181,7 +234,7 @@ function Connections() {
{appleMusicAvailable && ( {appleMusicAvailable && (
<View style={[styles.connectionColumn, styles.connection]}> <View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={styles.provider}>Apple Music</Text> <ServiceName service="apple_music" />
{status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')} {status('apple_music', 'One tap. Live while the app is open, recently played otherwise.')}
</View> </View>
{isActive('apple_music') ? ( {isActive('apple_music') ? (
@@ -196,7 +249,7 @@ function Connections() {
its own Last.fm / ListenBrainz link (or the Android toggle). */} its own Last.fm / ListenBrainz link (or the Android toggle). */}
<View style={[styles.connectionColumn, styles.connection]}> <View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={styles.provider}>Spotify</Text> <ServiceName service="spotify" />
<Body muted style={styles.small}> <Body muted style={styles.small}>
{Platform.OS === 'android' {Platform.OS === 'android'
? 'Shared automatically by “This phone” above. On other devices, connect it through Last.fm or ListenBrainz.' ? 'Shared automatically by “This phone” above. On other devices, connect it through Last.fm or ListenBrainz.'
@@ -210,7 +263,7 @@ function Connections() {
<View style={[styles.connectionColumn, styles.connection]}> <View style={[styles.connectionColumn, styles.connection]}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={styles.provider}>Plex</Text> <ServiceName service="plex" />
{status('plex', 'Music you play from your Plex servers')} {status('plex', 'Music you play from your Plex servers')}
</View> </View>
{isActive('plex') ? ( {isActive('plex') ? (
@@ -223,7 +276,7 @@ function Connections() {
<View style={styles.connectionColumn}> <View style={styles.connectionColumn}>
<View style={styles.connection}> <View style={styles.connection}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={styles.provider}>Last.fm</Text> <ServiceName service="lastfm" />
{status('lastfm', 'Scrobbles from Spotify, TIDAL, YouTube Music and more.')} {status('lastfm', 'Scrobbles from Spotify, TIDAL, YouTube Music and more.')}
</View> </View>
{byProvider.has('lastfm') ? ( {byProvider.has('lastfm') ? (
@@ -280,7 +333,7 @@ function UsernameConnection({
<View style={styles.connectionColumn}> <View style={styles.connectionColumn}>
<View style={styles.connection}> <View style={styles.connection}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={styles.provider}>{SERVICE_LABELS[provider]}</Text> <ServiceName service={provider} />
{status} {status}
</View> </View>
{active && <Button small title="Disconnect" onPress={onDisconnect} />} {active && <Button small title="Disconnect" onPress={onDisconnect} />}
@@ -327,7 +380,7 @@ function JellyfinConnection({
<View style={styles.connectionColumn}> <View style={styles.connectionColumn}>
<View style={styles.connection}> <View style={styles.connection}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={styles.provider}>Jellyfin</Text> <ServiceName service="jellyfin" />
{status} {status}
</View> </View>
{active && <Button small title="Disconnect" onPress={onDisconnect} />} {active && <Button small title="Disconnect" onPress={onDisconnect} />}
@@ -398,7 +451,7 @@ function Account() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
small: { fontSize: 13 }, small: { fontSize: 13 },
provider: { color: colors.text, fontWeight: '700', fontSize: 15 }, avatarRow: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
connection: { flexDirection: 'row', alignItems: 'center', gap: space.md }, connection: { flexDirection: 'row', alignItems: 'center', gap: space.md },
connectionColumn: { connectionColumn: {
gap: space.sm, gap: space.sm,
@@ -0,0 +1,31 @@
import { StyleSheet, Text, View } from 'react-native';
import { SERVICE_COLORS, SERVICE_LABELS, type NowPlayingProvider, type StreamingService } from '@musicshare/shared';
import { colors, fonts } from '@/lib/theme';
/** A service's name with a badge in its brand colour, so it's easy to spot in a list. */
export function ServiceName({ service }: { service: StreamingService | NowPlayingProvider }) {
const label = SERVICE_LABELS[service];
return (
<View style={styles.row}>
<View style={[styles.badge, { backgroundColor: SERVICE_COLORS[service] }]}>
<Text style={styles.letter}>{label.slice(0, 1).toUpperCase()}</Text>
</View>
<Text style={styles.name}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 2 },
badge: {
width: 28,
height: 28,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.25)',
},
letter: { color: '#fff', fontFamily: fonts.display, fontSize: 15 },
name: { color: colors.text, fontFamily: fonts.display, fontSize: 18 },
});
+4
View File
@@ -66,6 +66,10 @@ input:focus, textarea:focus, select:focus {
.container { max-width: 680px; margin: 0 auto; padding: 1.75rem 1rem 5rem; } .container { max-width: 680px; margin: 0 auto; padding: 1.75rem 1rem 5rem; }
.stack { display: flex; flex-direction: column; gap: 1rem; } .stack { display: flex; flex-direction: column; gap: 1rem; }
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.service-name { display: flex; align-items: center; gap: 0.6rem; font-family: var(--font-display); font-size: 1.12rem; font-weight: 700; margin-bottom: 0.2rem; }
.service-badge { width: 1.7rem; height: 1.7rem; border-radius: 0.5rem; display: grid; place-items: center; color: #fff; font-size: 0.95rem; border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; }
.avatar-picker { display: flex; gap: 1rem; align-items: center; }
.avatar-picker-actions { display: flex; flex-direction: column; gap: 0.4rem; }
.grow { flex: 1; min-width: 0; } .grow { flex: 1; min-width: 0; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.error { color: var(--danger); margin: 0; } .error { color: var(--danger); margin: 0; }
+56 -7
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useState } from 'react'; import { Suspense, useRef, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
SERVICE_LABELS, SERVICE_LABELS,
@@ -13,9 +13,11 @@ import {
type NowPlayingProvider, type NowPlayingProvider,
type TrackSearchResultDTO, type TrackSearchResultDTO,
} from '@musicshare/shared'; } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls'; import { SharingControls } from '@/components/SharingControls';
import { SupportCard } from '@/components/SupportLink'; import { SupportCard } from '@/components/SupportLink';
import { api, fetcher, useMe } from '@/lib/api'; import { api, fetcher, uploadFile, useMe } from '@/lib/api';
import { timeAgo } from '@musicshare/shared'; import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced'; import { useDebounced } from '@/lib/useDebounced';
@@ -71,6 +73,7 @@ function ProfileForm({ me }: { me: MeDTO }) {
return ( return (
<form className="card form" onSubmit={onSubmit}> <form className="card form" onSubmit={onSubmit}>
<h2>Profile</h2> <h2>Profile</h2>
<AvatarPicker me={me} />
<label> <label>
Display name Display name
<input name="displayName" defaultValue={me.displayName ?? ''} maxLength={64} /> <input name="displayName" defaultValue={me.displayName ?? ''} maxLength={64} />
@@ -96,6 +99,52 @@ function ProfileForm({ me }: { me: MeDTO }) {
); );
} }
function AvatarPicker({ me }: { me: MeDTO }) {
const { mutate } = useMe();
const input = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async (fn: () => Promise<MeDTO>) => {
setBusy(true);
setError(null);
try {
await mutate(await fn(), { revalidate: false });
} catch (err) {
setError((err as Error).message);
}
setBusy(false);
};
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // picking the same file again should still upload
if (!file) return;
if (file.size > 10 * 1024 * 1024) return setError('That picture is too big (10 MB max)');
run(() => uploadFile<MeDTO>('/me/avatar', file));
};
return (
<div className="avatar-picker">
<Avatar user={me} size={72} />
<div className="avatar-picker-actions">
<div className="row">
<button type="button" className="btn" disabled={busy} onClick={() => input.current?.click()}>
{busy ? 'Uploading…' : me.avatarUrl ? 'Change picture' : 'Add a picture'}
</button>
{me.avatarUrl && (
<button type="button" className="btn btn-small" disabled={busy} onClick={() => run(() => api<MeDTO>('/me/avatar', { method: 'DELETE' }))}>
Remove
</button>
)}
</div>
{error && <p className="error">{error}</p>}
</div>
<input ref={input} type="file" accept="image/*" hidden onChange={onPick} />
</div>
);
}
function Connections() { function Connections() {
const params = useSearchParams(); const params = useSearchParams();
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher); const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections', fetcher);
@@ -154,7 +203,7 @@ function Connections() {
{/* Spotify caps new apps at a handful of accounts; Spotify's own Last.fm/ListenBrainz link works for everyone. */} {/* Spotify caps new apps at a handful of accounts; Spotify's own Last.fm/ListenBrainz link works for everyone. */}
<div className="connection spotify-how"> <div className="connection spotify-how">
<div> <div>
<strong>Spotify</strong> <ServiceName service="spotify" />
<div className="muted"> <div className="muted">
Spotify sends everything you play to <strong>Last.fm</strong> or <strong>ListenBrainz</strong>, and we share it from Spotify sends everything you play to <strong>Last.fm</strong> or <strong>ListenBrainz</strong>, and we share it from
there. Connect one of them below, then link Spotify on their site: there. Connect one of them below, then link Spotify on their site:
@@ -181,7 +230,7 @@ function Connections() {
<div className="connection"> <div className="connection">
<div> <div>
<strong>Plex</strong> <ServiceName service="plex" />
<div>{status('plex') ?? <span className="muted">Music you play from your Plex servers</span>}</div> <div>{status('plex') ?? <span className="muted">Music you play from your Plex servers</span>}</div>
<WebhookHint connection={byProvider.get('plex')} /> <WebhookHint connection={byProvider.get('plex')} />
</div> </div>
@@ -198,7 +247,7 @@ function Connections() {
<div className="connection"> <div className="connection">
<div className="grow"> <div className="grow">
<strong>Jellyfin</strong> <ServiceName service="jellyfin" />
<div>{status('jellyfin') ?? <span className="muted">Sign in to your Jellyfin server. Your password isn&apos;t stored.</span>}</div> <div>{status('jellyfin') ?? <span className="muted">Sign in to your Jellyfin server. Your password isn&apos;t stored.</span>}</div>
<WebhookHint connection={byProvider.get('jellyfin')} /> <WebhookHint connection={byProvider.get('jellyfin')} />
{byProvider.get('jellyfin')?.status !== 'active' && ( {byProvider.get('jellyfin')?.status !== 'active' && (
@@ -230,7 +279,7 @@ function Connections() {
{(['lastfm', 'listenbrainz'] as const).map((p) => ( {(['lastfm', 'listenbrainz'] as const).map((p) => (
<div className="connection" key={p}> <div className="connection" key={p}>
<div> <div>
<strong>{SERVICE_LABELS[p]}</strong> <ServiceName service={p} />
<div> <div>
{status(p) ?? ( {status(p) ?? (
<span className="muted"> <span className="muted">
@@ -267,7 +316,7 @@ function Connections() {
<div className={`connection ${byProvider.has('apple_music') ? '' : 'disabled'}`}> <div className={`connection ${byProvider.has('apple_music') ? '' : 'disabled'}`}>
<div> <div>
<strong>Apple Music</strong> <ServiceName service="apple_music" />
<div className="muted">{byProvider.has('apple_music') ? status('apple_music') : 'Connect from the musicshare iPhone app (one tap)'}</div> <div className="muted">{byProvider.has('apple_music') ? status('apple_music') : 'Connect from the musicshare iPhone app (one tap)'}</div>
</div> </div>
</div> </div>
+14
View File
@@ -0,0 +1,14 @@
import { SERVICE_COLORS, SERVICE_LABELS, type NowPlayingProvider, type StreamingService } from '@musicshare/shared';
/** A service's name with a badge in its brand colour, so it's easy to spot in a list. */
export function ServiceName({ service }: { service: StreamingService | NowPlayingProvider }) {
const label = SERVICE_LABELS[service];
return (
<span className="service-name">
<span className="service-badge" style={{ background: SERVICE_COLORS[service] }} aria-hidden>
{label.slice(0, 1).toUpperCase()}
</span>
{label}
</span>
);
}
+10
View File
@@ -26,6 +26,16 @@ export async function api<T = unknown>(path: string, init: { method?: string; bo
return data as T; return data as T;
} }
/** Uploads one file as multipart form data (e.g. the profile picture). */
export async function uploadFile<T = unknown>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append('file', file);
const res = await fetch(API_URL + path, { method: 'POST', credentials: 'include', body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? res.statusText);
return data as T;
}
export const fetcher = <T,>(path: string) => api<T>(path); export const fetcher = <T,>(path: string) => api<T>(path);
export function useMe() { export function useMe() {
+69 -2
View File
@@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
"@fastify/multipart": "^10.1.2",
"@fastify/rate-limit": "^11.2.0", "@fastify/rate-limit": "^11.2.0",
"@fastify/websocket": "^11.3.2", "@fastify/websocket": "^11.3.2",
"@musicshare/shared": "*", "@musicshare/shared": "*",
@@ -33,6 +34,7 @@
"fastify": "^5.12.5", "fastify": "^5.12.5",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sharp": "^0.35.5",
"undici": "^6.29.0", "undici": "^6.29.0",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
@@ -815,6 +817,7 @@
"expo-glass-effect": "~57.0.4", "expo-glass-effect": "~57.0.4",
"expo-haptics": "~57.0.3", "expo-haptics": "~57.0.3",
"expo-image": "~57.0.5", "expo-image": "~57.0.5",
"expo-image-picker": "~57.0.20",
"expo-linear-gradient": "~57.0.2", "expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.11", "expo-linking": "~57.0.11",
"expo-router": "~57.0.24", "expo-router": "~57.0.24",
@@ -4313,6 +4316,12 @@
"fast-uri": "^4.0.0" "fast-uri": "^4.0.0"
} }
}, },
"node_modules/@fastify/busboy": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz",
"integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==",
"license": "MIT"
},
"node_modules/@fastify/cookie": { "node_modules/@fastify/cookie": {
"version": "11.1.2", "version": "11.1.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
@@ -4353,6 +4362,22 @@
"toad-cache": "^3.7.0" "toad-cache": "^3.7.0"
} }
}, },
"node_modules/@fastify/deepmerge": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.2.tgz",
"integrity": "sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/error": { "node_modules/@fastify/error": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@@ -4423,6 +4448,29 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/@fastify/multipart": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.2.tgz",
"integrity": "sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^3.0.0",
"@fastify/deepmerge": "^3.0.0",
"@fastify/error": "^4.0.0",
"fastify-plugin": "^6.0.0",
"secure-json-parse": "^4.0.0"
}
},
"node_modules/@fastify/proxy-addr": { "node_modules/@fastify/proxy-addr": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz",
@@ -4557,7 +4605,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -11169,6 +11216,27 @@
} }
} }
}, },
"node_modules/expo-image-loader": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz",
"integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "57.0.20",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",
"integrity": "sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~57.0.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-keep-awake": { "node_modules/expo-keep-awake": {
"version": "57.0.2", "version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz",
@@ -17416,7 +17484,6 @@
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
+14
View File
@@ -46,6 +46,17 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
return data as T; return data as T;
} }
/** Multipart upload (the browser/React Native sets the boundary itself). */
async function upload<T>(path: string, form: FormData): Promise<T> {
const headers: Record<string, string> = {};
const token = getToken?.();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(baseUrl + path, { method: 'POST', headers, credentials, body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? `Upload failed (${res.status})`);
return data as T;
}
const q = encodeURIComponent; const q = encodeURIComponent;
return { return {
@@ -60,6 +71,9 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
me: () => request<MeDTO>('/me'), me: () => request<MeDTO>('/me'),
updateMe: (input: UpdateMeInput) => request<MeDTO>('/me', { method: 'PATCH', body: input }), updateMe: (input: UpdateMeInput) => request<MeDTO>('/me', { method: 'PATCH', body: input }),
deleteAccount: () => request('/me', { method: 'DELETE' }), deleteAccount: () => request('/me', { method: 'DELETE' }),
/** `form` holds one file field (any name): a File on web, `{ uri, name, type }` on React Native. */
uploadAvatar: (form: FormData) => upload<MeDTO>('/me/avatar', form),
removeAvatar: () => request<MeDTO>('/me/avatar', { method: 'DELETE' }),
setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request<MeDTO>('/me/sharing', { method: 'PUT', body: { pause } }), setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request<MeDTO>('/me/sharing', { method: 'PUT', body: { pause } }),
setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }), setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }),
clearFeatured: () => request('/me/featured', { method: 'DELETE' }), clearFeatured: () => request('/me/featured', { method: 'DELETE' }),
-1
View File
@@ -24,7 +24,6 @@ export const loginSchema = z.object({
export const updateMeSchema = z.object({ export const updateMeSchema = z.object({
displayName: z.string().trim().max(64).nullable().optional(), displayName: z.string().trim().max(64).nullable().optional(),
bio: z.string().trim().max(280).nullable().optional(), bio: z.string().trim().max(280).nullable().optional(),
avatarUrl: z.url().max(500).nullable().optional(),
preferredService: z.enum(STREAMING_SERVICES).nullable().optional(), preferredService: z.enum(STREAMING_SERVICES).nullable().optional(),
/** Mark onboarding as finished. */ /** Mark onboarding as finished. */
onboarded: z.literal(true).optional(), onboarded: z.literal(true).optional(),
+14
View File
@@ -33,6 +33,20 @@ export const SERVICE_LABELS: Record<StreamingService | NowPlayingProvider, strin
android_media_session: 'your phone', android_media_session: 'your phone',
}; };
/** Brand colours, for the badge shown next to a service's name. */
export const SERVICE_COLORS: Record<StreamingService | NowPlayingProvider, string> = {
spotify: '#1DB954',
apple_music: '#FA2D48',
youtube_music: '#FF0033',
deezer: '#A238FF',
tidal: '#000000',
lastfm: '#D51007',
listenbrainz: '#EB743B',
plex: '#E5A00D',
jellyfin: '#AA5CC3',
android_media_session: '#5b4cf0',
};
/** Fallback when we have no direct link: a search on that service. */ /** Fallback when we have no direct link: a search on that service. */
export function searchUrl(service: StreamingService, title: string, artist: string): string { export function searchUrl(service: StreamingService, title: string, artist: string): string {
const q = encodeURIComponent(`${artist} ${title}`); const q = encodeURIComponent(`${artist} ${title}`);