Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a26e33bfe | ||
|
|
864ab88627 | ||
|
|
2025225fe4 | ||
|
|
4402171f46 | ||
|
|
31aed8dec4 | ||
|
|
b705bfcce4 | ||
|
|
82ff5336aa |
@@ -94,7 +94,7 @@ jobs:
|
|||||||
auth="Authorization: token $TOKEN"
|
auth="Authorization: token $TOKEN"
|
||||||
if [ "$REF_TYPE" = tag ]; then
|
if [ "$REF_TYPE" = tag ]; then
|
||||||
# Reuse the release if this tag was built before.
|
# Reuse the release if this tag was built before.
|
||||||
id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0)).id' || true)
|
id=$(curl -fsS -H "$auth" "$api/releases/tags/$REF_NAME" 2>/dev/null | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8") || "{}").id ?? ""' || true)
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
# The changelog first (the app shows the text above "---" as what's new), then a note for this page.
|
# The changelog first (the app shows the text above "---" as what's new), then a note for this page.
|
||||||
body=$(node -e '
|
body=$(node -e '
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"listen_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"body" varchar(500) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "comment_body" varchar(500);--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_listen_created_idx" ON "comments" USING btree ("listen_id","created_at");--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reports" ADD CONSTRAINT "reports_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1792028149643,
|
"when": 1792028149643,
|
||||||
"tag": "0016_notifications",
|
"tag": "0016_notifications",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792084480262,
|
||||||
|
"tag": "0017_comments",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -12,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 { appUpdateRoutes } from './routes/appUpdates';
|
import { appUpdateRoutes } from './routes/appUpdates';
|
||||||
|
import { commentRoutes } from './routes/comments';
|
||||||
import { accountRoutes } from './routes/account';
|
import { accountRoutes } from './routes/account';
|
||||||
import { authRoutes } from './routes/auth';
|
import { authRoutes } from './routes/auth';
|
||||||
import { avatarRoutes } from './routes/avatars';
|
import { avatarRoutes } from './routes/avatars';
|
||||||
@@ -78,6 +79,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
|
|||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(trackRoutes);
|
await app.register(trackRoutes);
|
||||||
await app.register(reactionRoutes);
|
await app.register(reactionRoutes);
|
||||||
|
await app.register(commentRoutes);
|
||||||
await app.register(notificationRoutes);
|
await app.register(notificationRoutes);
|
||||||
await app.register(connectionRoutes);
|
await app.register(connectionRoutes);
|
||||||
await app.register(deviceRoutes);
|
await app.register(deviceRoutes);
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ export const reports = pgTable(
|
|||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
reason: text('reason').$type<ReportReason>().notNull(),
|
reason: text('reason').$type<ReportReason>().notNull(),
|
||||||
note: varchar('note', { length: 500 }),
|
note: varchar('note', { length: 500 }),
|
||||||
|
/** When a comment was reported: which, and what it said then (kept if the comment is deleted). */
|
||||||
|
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'set null' }),
|
||||||
|
commentBody: varchar('comment_body', { length: 500 }),
|
||||||
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
|
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
|
||||||
createdAt: createdAt(),
|
createdAt: createdAt(),
|
||||||
},
|
},
|
||||||
@@ -262,6 +265,23 @@ export const reactions = pgTable(
|
|||||||
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
|
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Comments on a play. They go with the play (plays with comments are kept when history is trimmed). */
|
||||||
|
export const comments = pgTable(
|
||||||
|
'comments',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
listenId: uuid('listen_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => listens.id, { onDelete: 'cascade' }),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
body: varchar('body', { length: 500 }).notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('comments_listen_created_idx').on(t.listenId, t.createdAt)],
|
||||||
|
);
|
||||||
|
|
||||||
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
|
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
|
||||||
export const emailVerifications = pgTable(
|
export const emailVerifications = pgTable(
|
||||||
'email_verifications',
|
'email_verifications',
|
||||||
@@ -306,7 +326,7 @@ export const lyrics = pgTable('lyrics', {
|
|||||||
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Things that happened to a user: a reaction, a new follower, a song sent to them. */
|
/** Things that happened to a user: a reaction or comment, a new follower, a song sent to them. */
|
||||||
export const notifications = pgTable(
|
export const notifications = pgTable(
|
||||||
'notifications',
|
'notifications',
|
||||||
{
|
{
|
||||||
@@ -319,10 +339,12 @@ export const notifications = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }),
|
trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }),
|
||||||
/** The play a reaction was on (gone once the play is trimmed from history). */
|
/** The play a reaction or comment was on (gone once the play is trimmed from history). */
|
||||||
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
|
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
|
||||||
|
/** The comment (or reply); deleting the comment takes its notifications with it. */
|
||||||
|
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'cascade' }),
|
||||||
emoji: varchar('emoji', { length: 32 }),
|
emoji: varchar('emoji', { length: 32 }),
|
||||||
/** A note sent with a song. */
|
/** A note sent with a song, or the start of a comment. */
|
||||||
message: varchar('message', { length: 280 }),
|
message: varchar('message', { length: 280 }),
|
||||||
readAt: timestamp('read_at', { withTimezone: true }),
|
readAt: timestamp('read_at', { withTimezone: true }),
|
||||||
createdAt: createdAt(),
|
createdAt: createdAt(),
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { env } from '../env';
|
|||||||
import { HttpError } from './http';
|
import { HttpError } from './http';
|
||||||
|
|
||||||
export const AVATAR_SIZE = 512;
|
export const AVATAR_SIZE = 512;
|
||||||
export const AVATAR_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
/** Full-size photos from 50-200 MP phone cameras can be well over 10 MB (the app shrinks them first since 1.3.0). */
|
||||||
|
export const AVATAR_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
|
* Uploaded avatars are stored as a path (`/avatars/<id>?v=<version>`) so they keep
|
||||||
@@ -16,14 +17,16 @@ export const publicAvatarUrl = (stored: string | null): string | null =>
|
|||||||
* Square-crops, resizes and re-encodes to WebP. Re-encoding also drops EXIF
|
* 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.
|
* (phone photos carry GPS location) and anything that isn't image data.
|
||||||
*/
|
*/
|
||||||
export async function processAvatar(input: Buffer): Promise<Buffer> {
|
export async function processAvatar(input: Buffer, log?: (err: unknown) => void): Promise<Buffer> {
|
||||||
try {
|
try {
|
||||||
return await sharp(input, { limitInputPixels: 50_000_000, animated: false })
|
// sharp's default pixel limit (~268 MP) fits a 200 MP camera; JPEGs are shrunk while decoding, so it stays cheap.
|
||||||
|
return await sharp(input, { animated: false })
|
||||||
.rotate() // apply the EXIF orientation before it's stripped
|
.rotate() // apply the EXIF orientation before it's stripped
|
||||||
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
|
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
|
||||||
.webp({ quality: 82 })
|
.webp({ quality: 82 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
log?.(err);
|
||||||
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
|
throw new HttpError(400, "That file isn't an image we can read. Try a JPEG, PNG, WebP or HEIC.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export async function adminRoutes(app: FastifyInstance) {
|
|||||||
createdAt: report.createdAt.toISOString(),
|
createdAt: report.createdAt.toISOString(),
|
||||||
reporter: by ? toUserSummary(by) : null,
|
reporter: by ? toUserSummary(by) : null,
|
||||||
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
|
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
|
||||||
|
comment: report.commentBody ? { id: report.commentId, body: report.commentBody } : null,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ export async function avatarRoutes(app: FastifyInstance) {
|
|||||||
const file = await req.file({ limits: { fileSize: AVATAR_MAX_UPLOAD_BYTES, files: 1 } }).catch(() => undefined);
|
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');
|
if (!file) throw new HttpError(400, 'Choose a picture to upload');
|
||||||
const input = await file.toBuffer().catch(() => {
|
const input = await file.toBuffer().catch(() => {
|
||||||
throw new HttpError(413, 'That picture is too big (10 MB max)');
|
throw new HttpError(413, `That picture is too big (${AVATAR_MAX_UPLOAD_BYTES / 1024 / 1024} MB max)`);
|
||||||
});
|
});
|
||||||
const image = await processAvatar(input);
|
const image = await processAvatar(input, (err) =>
|
||||||
|
req.log.warn({ err, bytes: input.length, mimetype: file.mimetype, filename: file.filename }, 'avatar not readable'),
|
||||||
|
);
|
||||||
|
|
||||||
const values = { image, contentType: 'image/webp', updatedAt: new Date() };
|
const values = { image, contentType: 'image/webp', updatedAt: new Date() };
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { and, asc, eq, inArray, isNull, ne, or } from 'drizzle-orm';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { commentSchema, reportSchema, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
|
||||||
|
import { db, schema } from '../db';
|
||||||
|
import type { User } from '../db/schema';
|
||||||
|
import { requireUser } from '../lib/auth';
|
||||||
|
import { HttpError } from '../lib/http';
|
||||||
|
import { blockStatus, hiddenUserIds } from '../services/blocks';
|
||||||
|
import { notify } from '../services/notifications';
|
||||||
|
import { getReactions, publishNowPlaying, toUserSummary } from '../services/nowPlaying';
|
||||||
|
import { getTrackDTOs } from '../services/tracks';
|
||||||
|
|
||||||
|
const params = z.object({ id: z.uuid() });
|
||||||
|
|
||||||
|
/** Who may write on a play: the person who played it, and the people they follow. */
|
||||||
|
async function canCommentOn(viewerId: string, ownerId: string) {
|
||||||
|
if (viewerId === ownerId) return true;
|
||||||
|
const [row] = await db
|
||||||
|
.select({ one: schema.follows.followerId })
|
||||||
|
.from(schema.follows)
|
||||||
|
.where(and(eq(schema.follows.followerId, ownerId), eq(schema.follows.followeeId, viewerId)));
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The play and its owner, if the viewer may see it: the owner isn't suspended, and no block either way. */
|
||||||
|
async function visiblePlay(viewerId: string, listenId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ listen: schema.listens, owner: schema.users })
|
||||||
|
.from(schema.listens)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.listens.userId))
|
||||||
|
.where(eq(schema.listens.id, listenId));
|
||||||
|
if (!row || row.owner.suspendedAt) throw new HttpError(404, 'That play is gone');
|
||||||
|
const { viewerBlocked, blockedByThem } = await blockStatus(viewerId, row.owner.id);
|
||||||
|
if (viewerBlocked || blockedByThem) throw new HttpError(404, 'That play is gone');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCommentDTO = (c: typeof schema.comments.$inferSelect, author: User, viewer: User, ownerId: string): CommentDTO => ({
|
||||||
|
id: c.id,
|
||||||
|
user: toUserSummary(author),
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.createdAt.toISOString(),
|
||||||
|
canDelete: c.userId === viewer.id || ownerId === viewer.id || viewer.role === 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function commentRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/listens/:id/comments', async (req): Promise<CommentThreadDTO> => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { listen, owner } = await visiblePlay(viewer.id, id);
|
||||||
|
const hidden = await hiddenUserIds(viewer.id);
|
||||||
|
const rows = await db
|
||||||
|
.select({ comment: schema.comments, author: schema.users })
|
||||||
|
.from(schema.comments)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
|
||||||
|
.where(and(eq(schema.comments.listenId, id), isNull(schema.users.suspendedAt)))
|
||||||
|
.orderBy(asc(schema.comments.createdAt));
|
||||||
|
const [tracks, reactions, canComment] = await Promise.all([
|
||||||
|
getTrackDTOs([listen.trackId]),
|
||||||
|
getReactions([id]),
|
||||||
|
canCommentOn(viewer.id, owner.id),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
listenId: id,
|
||||||
|
owner: toUserSummary(owner),
|
||||||
|
track: tracks.get(listen.trackId)!,
|
||||||
|
playedAt: listen.startedAt.toISOString(),
|
||||||
|
reactions: reactions.get(id) ?? [],
|
||||||
|
comments: rows.filter((r) => !hidden.has(r.author.id)).map((r) => toCommentDTO(r.comment, r.author, viewer, owner.id)),
|
||||||
|
canComment,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/listens/:id/comments',
|
||||||
|
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||||
|
async (req): Promise<CommentDTO> => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { body } = commentSchema.parse(req.body);
|
||||||
|
const { listen, owner } = await visiblePlay(viewer.id, id);
|
||||||
|
if (!(await canCommentOn(viewer.id, owner.id))) {
|
||||||
|
throw new HttpError(403, `Only people @${owner.username} follows can comment on their songs`);
|
||||||
|
}
|
||||||
|
const [comment] = await db.insert(schema.comments).values({ listenId: id, userId: viewer.id, body }).returning();
|
||||||
|
|
||||||
|
const excerpt = body.length > 280 ? `${body.slice(0, 279)}…` : body;
|
||||||
|
const about = { actorId: viewer.id, listenId: id, trackId: listen.trackId, commentId: comment!.id, message: excerpt };
|
||||||
|
await notify({ ...about, userId: owner.id, type: 'comment' });
|
||||||
|
// Everyone else who has commented here hears about replies (unless either side has blocked the other).
|
||||||
|
const others = await db
|
||||||
|
.selectDistinct({ userId: schema.comments.userId })
|
||||||
|
.from(schema.comments)
|
||||||
|
.where(and(eq(schema.comments.listenId, id), ne(schema.comments.userId, viewer.id), ne(schema.comments.userId, owner.id)));
|
||||||
|
const hidden = await hiddenUserIds(viewer.id);
|
||||||
|
for (const { userId } of others) {
|
||||||
|
if (!hidden.has(userId)) await notify({ ...about, userId, type: 'reply' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishNowPlaying(owner.id); // the comment count on the feed
|
||||||
|
return toCommentDTO(comment!, viewer, viewer, owner.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function findComment(commentId: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ comment: schema.comments, ownerId: schema.listens.userId })
|
||||||
|
.from(schema.comments)
|
||||||
|
.innerJoin(schema.listens, eq(schema.listens.id, schema.comments.listenId))
|
||||||
|
.where(eq(schema.comments.id, commentId));
|
||||||
|
if (!row) throw new HttpError(404, 'That comment is gone');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The author, the play's owner, or an admin. Its notifications go with it. */
|
||||||
|
app.delete('/comments/:id', async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { comment, ownerId } = await findComment(id);
|
||||||
|
if (comment.userId !== viewer.id && ownerId !== viewer.id && viewer.role !== 'admin') {
|
||||||
|
throw new HttpError(403, "You can't delete that comment");
|
||||||
|
}
|
||||||
|
await db.delete(schema.comments).where(eq(schema.comments.id, id));
|
||||||
|
await publishNowPlaying(ownerId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/comments/:id/report',
|
||||||
|
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
|
||||||
|
async (req) => {
|
||||||
|
const viewer = requireUser(req);
|
||||||
|
const { id } = params.parse(req.params);
|
||||||
|
const { comment } = await findComment(id);
|
||||||
|
await visiblePlay(viewer.id, comment.listenId);
|
||||||
|
if (comment.userId === viewer.id) throw new HttpError(400, "You can't report your own comment");
|
||||||
|
const input = reportSchema.parse(req.body);
|
||||||
|
await db.insert(schema.reports).values({
|
||||||
|
reporterId: viewer.id,
|
||||||
|
reportedId: comment.userId,
|
||||||
|
reason: input.reason,
|
||||||
|
note: input.note || null,
|
||||||
|
commentId: comment.id,
|
||||||
|
commentBody: comment.body,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When someone's blocked, their comments on each other's plays go too. */
|
||||||
|
export async function removeCommentsBetween(a: string, b: string) {
|
||||||
|
const playsOf = (userId: string) => db.select({ id: schema.listens.id }).from(schema.listens).where(eq(schema.listens.userId, userId));
|
||||||
|
await db
|
||||||
|
.delete(schema.comments)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(eq(schema.comments.userId, a), inArray(schema.comments.listenId, playsOf(b))),
|
||||||
|
and(eq(schema.comments.userId, b), inArray(schema.comments.listenId, playsOf(a))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { HttpError } from '../lib/http';
|
|||||||
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||||
import { removeNotificationsBetween } from '../services/notifications';
|
import { removeNotificationsBetween } from '../services/notifications';
|
||||||
import { toUserSummary } from '../services/nowPlaying';
|
import { toUserSummary } from '../services/nowPlaying';
|
||||||
|
import { removeCommentsBetween } from './comments';
|
||||||
import { removeReactionsBetween } from './reactions';
|
import { removeReactionsBetween } from './reactions';
|
||||||
|
|
||||||
async function findUser(username: string) {
|
async function findUser(username: string) {
|
||||||
@@ -49,6 +50,7 @@ export async function safetyRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
await removeReactionsBetween(me.id, them.id);
|
await removeReactionsBetween(me.id, them.id);
|
||||||
|
await removeCommentsBetween(me.id, them.id);
|
||||||
await removeNotificationsBetween(me.id, them.id);
|
await removeNotificationsBetween(me.id, them.id);
|
||||||
await followsChanged(me.id, them.id);
|
await followsChanged(me.id, them.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -65,10 +65,8 @@ async function lookup(track: { title: string; artist: string; album: string | nu
|
|||||||
if (track.durationMs) {
|
if (track.durationMs) {
|
||||||
const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) });
|
const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) });
|
||||||
if (track.album) params.set('album_name', track.album);
|
if (track.album) params.set('album_name', track.album);
|
||||||
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch((e: unknown) => {
|
// Not found, or LRCLIB's exact lookup is overloaded (it rate-limits /api/get separately): try the search.
|
||||||
if (e instanceof HttpError && e.status === 404) return null;
|
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch(() => null);
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams({ track_name: track.title, artist_name: artist });
|
const params = new URLSearchParams({ track_name: track.title, artist_name: artist });
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function notify(input: {
|
|||||||
listenId?: string | null;
|
listenId?: string | null;
|
||||||
emoji?: string | null;
|
emoji?: string | null;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
|
commentId?: string | null;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
if (input.userId === input.actorId) return null;
|
if (input.userId === input.actorId) return null;
|
||||||
const n = schema.notifications;
|
const n = schema.notifications;
|
||||||
@@ -91,6 +92,7 @@ export async function toNotificationDTOs(rows: Row[]): Promise<NotificationDTO[]
|
|||||||
type: r.type,
|
type: r.type,
|
||||||
actor,
|
actor,
|
||||||
track: r.trackId ? (tracks.get(r.trackId) ?? null) : null,
|
track: r.trackId ? (tracks.get(r.trackId) ?? null) : null,
|
||||||
|
listenId: r.listenId,
|
||||||
emoji: r.emoji,
|
emoji: r.emoji,
|
||||||
message: r.message,
|
message: r.message,
|
||||||
read: Boolean(r.readAt),
|
read: Boolean(r.readAt),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
|
import { and, count, desc, eq, inArray, isNull, lt, notExists } from 'drizzle-orm';
|
||||||
import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
|
import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, 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';
|
||||||
@@ -40,6 +40,18 @@ export async function getReactions(listenIds: string[]): Promise<Map<string, Rea
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many comments each play has (from people who aren't suspended). */
|
||||||
|
export async function getCommentCounts(listenIds: string[]): Promise<Map<string, number>> {
|
||||||
|
if (listenIds.length === 0) return new Map();
|
||||||
|
const rows = await db
|
||||||
|
.select({ listenId: schema.comments.listenId, n: count() })
|
||||||
|
.from(schema.comments)
|
||||||
|
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
|
||||||
|
.where(and(inArray(schema.comments.listenId, listenIds), isNull(schema.users.suspendedAt)))
|
||||||
|
.groupBy(schema.comments.listenId);
|
||||||
|
return new Map(rows.map((r) => [r.listenId, r.n]));
|
||||||
|
}
|
||||||
|
|
||||||
/** 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". */
|
||||||
export const DEVICE_STALE_MS = 9 * 60_000;
|
export const DEVICE_STALE_MS = 9 * 60_000;
|
||||||
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
|
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
|
||||||
@@ -63,7 +75,8 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
|
|||||||
const l = listenIds.get(np.userId);
|
const l = listenIds.get(np.userId);
|
||||||
return l && l.trackId === np.trackId ? l.id : null;
|
return l && l.trackId === np.trackId ? l.id : null;
|
||||||
};
|
};
|
||||||
const reactions = await getReactions(rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id)));
|
const shownListens = rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id));
|
||||||
|
const [reactions, commentCounts] = await Promise.all([getReactions(shownListens), getCommentCounts(shownListens)]);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return rows
|
return rows
|
||||||
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
|
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
|
||||||
@@ -78,6 +91,7 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
|
|||||||
updatedAt: np.updatedAt.toISOString(),
|
updatedAt: np.updatedAt.toISOString(),
|
||||||
listenId: listenFor(np),
|
listenId: listenFor(np),
|
||||||
reactions: reactions.get(listenFor(np) ?? '') ?? [],
|
reactions: reactions.get(listenFor(np) ?? '') ?? [],
|
||||||
|
commentCount: commentCounts.get(listenFor(np) ?? '') ?? 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
|
||||||
}
|
}
|
||||||
@@ -108,7 +122,11 @@ async function recordListen(userId: string, trackId: string, source: NowPlayingP
|
|||||||
.offset(LISTEN_HISTORY_LIMIT - 1)
|
.offset(LISTEN_HISTORY_LIMIT - 1)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (cutoff) {
|
if (cutoff) {
|
||||||
await db.delete(schema.listens).where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt)));
|
// Plays with comments stay, so conversations don't vanish as new songs come in.
|
||||||
|
const commented = db.select({ one: schema.comments.id }).from(schema.comments).where(eq(schema.comments.listenId, schema.listens.id));
|
||||||
|
await db
|
||||||
|
.delete(schema.listens)
|
||||||
|
.where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt), notExists(commented)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,13 +140,19 @@ export async function getRecentPlays(userId: string, limit = 10): Promise<Recent
|
|||||||
// Don't repeat the track that's playing right now.
|
// Don't repeat the track that's playing right now.
|
||||||
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
|
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
|
||||||
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
|
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
|
||||||
const [tracks, reactions] = await Promise.all([getTrackDTOs(plays.map((p) => p.trackId)), getReactions(plays.map((p) => p.id))]);
|
const ids = plays.map((p) => p.id);
|
||||||
|
const [tracks, reactions, commentCounts] = await Promise.all([
|
||||||
|
getTrackDTOs(plays.map((p) => p.trackId)),
|
||||||
|
getReactions(ids),
|
||||||
|
getCommentCounts(ids),
|
||||||
|
]);
|
||||||
return plays.map((p) => ({
|
return plays.map((p) => ({
|
||||||
listenId: p.id,
|
listenId: p.id,
|
||||||
track: tracks.get(p.trackId)!,
|
track: tracks.get(p.trackId)!,
|
||||||
source: p.source,
|
source: p.source,
|
||||||
playedAt: p.startedAt.toISOString(),
|
playedAt: p.startedAt.toISOString(),
|
||||||
reactions: reactions.get(p.id) ?? [],
|
reactions: reactions.get(p.id) ?? [],
|
||||||
|
commentCount: commentCounts.get(p.id) ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ const photo = () =>
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
describe('avatars', () => {
|
describe('avatars', () => {
|
||||||
|
it('takes a full-size photo from a high-resolution phone camera (64 MP)', async () => {
|
||||||
|
const { token } = await createUser(app(), 'hires');
|
||||||
|
const big = await sharp({ create: { width: 8000, height: 8000, channels: 3, background: '#203040' } }).jpeg().toBuffer();
|
||||||
|
const { payload, headers } = multipart(big);
|
||||||
|
const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it('resizes, re-encodes and serves an uploaded picture', async () => {
|
it('resizes, re-encodes and serves an uploaded picture', async () => {
|
||||||
const { token, user } = await createUser(app(), 'alice');
|
const { token, user } = await createUser(app(), 'alice');
|
||||||
const { payload, headers } = multipart(await photo());
|
const { payload, headers } = multipart(await photo());
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { applyPollResult, LISTEN_HISTORY_LIMIT } from '../src/services/nowPlaying';
|
||||||
|
import { bearer, createUser, follow, useTestApp } from './helpers';
|
||||||
|
|
||||||
|
const app = useTestApp();
|
||||||
|
const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
|
||||||
|
|
||||||
|
/** Alice plays a song. Bob follows her and she follows him back; Carol follows her, but she doesn't follow Carol. */
|
||||||
|
async function setup() {
|
||||||
|
const alice = await createUser(app(), 'alice');
|
||||||
|
const bob = await createUser(app(), 'bob');
|
||||||
|
const carol = await createUser(app(), 'carol');
|
||||||
|
await follow(app(), bob.token, 'alice');
|
||||||
|
await follow(app(), alice.token, 'bob');
|
||||||
|
await follow(app(), carol.token, 'alice');
|
||||||
|
await applyPollResult(alice.user.id, 'lastfm', song('Teardrop'));
|
||||||
|
const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
|
||||||
|
return { alice, bob, carol, listenId: feed[0].listenId as string };
|
||||||
|
}
|
||||||
|
const comment = (token: string, listenId: string, body: string) =>
|
||||||
|
app().inject({ method: 'POST', url: `/listens/${listenId}/comments`, headers: bearer(token), payload: { body } });
|
||||||
|
const thread = (token: string, listenId: string) => app().inject({ url: `/listens/${listenId}/comments`, headers: bearer(token) });
|
||||||
|
/** Comment and reply notifications (setup's follows make some too). */
|
||||||
|
const notifications = async (token: string) =>
|
||||||
|
((await app().inject({ url: '/notifications', headers: bearer(token) })).json().notifications as { type: string; message: string }[]).filter(
|
||||||
|
(n) => n.type === 'comment' || n.type === 'reply',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('comments', () => {
|
||||||
|
it('lets the player and people they follow comment, oldest first, with a count on the feed', async () => {
|
||||||
|
const { alice, bob, carol, listenId } = await setup();
|
||||||
|
expect((await comment(bob.token, listenId, ' what a tune ')).json()).toMatchObject({ body: 'what a tune', canDelete: true });
|
||||||
|
await comment(alice.token, listenId, 'right?');
|
||||||
|
|
||||||
|
const res = await comment(carol.token, listenId, 'hi');
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.json().error).toBe('Only people @alice follows can comment on their songs');
|
||||||
|
|
||||||
|
const forCarol = (await thread(carol.token, listenId)).json();
|
||||||
|
expect(forCarol).toMatchObject({ canComment: false, owner: { username: 'alice' }, track: { title: 'Teardrop' } });
|
||||||
|
expect(forCarol.comments.map((c: { body: string; canDelete: boolean }) => [c.body, c.canDelete])).toEqual([
|
||||||
|
['what a tune', false],
|
||||||
|
['right?', false],
|
||||||
|
]);
|
||||||
|
expect((await thread(alice.token, listenId)).json().comments.every((c: { canDelete: boolean }) => c.canDelete)).toBe(true);
|
||||||
|
|
||||||
|
const feed = (await app().inject({ url: '/feed', headers: bearer(carol.token) })).json();
|
||||||
|
expect(feed[0].commentCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty and overlong comments', async () => {
|
||||||
|
const { bob, listenId } = await setup();
|
||||||
|
expect((await comment(bob.token, listenId, ' ')).statusCode).toBe(400);
|
||||||
|
expect((await comment(bob.token, listenId, 'x'.repeat(501))).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies the player, and earlier commenters about replies', async () => {
|
||||||
|
const { alice, bob, listenId } = await setup();
|
||||||
|
const dave = await createUser(app(), 'dave');
|
||||||
|
await follow(app(), alice.token, 'dave');
|
||||||
|
|
||||||
|
await comment(bob.token, listenId, 'what a tune');
|
||||||
|
await comment(dave.token, listenId, 'agreed');
|
||||||
|
await comment(alice.token, listenId, 'thanks both');
|
||||||
|
|
||||||
|
expect((await notifications(alice.token)).map((n) => [n.type, n.message])).toEqual([
|
||||||
|
['comment', 'agreed'],
|
||||||
|
['comment', 'what a tune'],
|
||||||
|
]);
|
||||||
|
expect((await notifications(bob.token)).map((n) => [n.type, n.message])).toEqual([
|
||||||
|
['reply', 'thanks both'],
|
||||||
|
['reply', 'agreed'],
|
||||||
|
]);
|
||||||
|
const [latest] = (await app().inject({ url: '/notifications', headers: bearer(bob.token) })).json().notifications;
|
||||||
|
expect(latest).toMatchObject({ type: 'reply', listenId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be deleted by the author, the player or an admin (taking its notifications along), not anyone else', async () => {
|
||||||
|
const { alice, bob, carol, listenId } = await setup();
|
||||||
|
const { id } = (await comment(bob.token, listenId, 'first')).json();
|
||||||
|
const del = (token: string) => app().inject({ method: 'DELETE', url: `/comments/${id}`, headers: bearer(token) });
|
||||||
|
expect((await del(carol.token)).statusCode).toBe(403);
|
||||||
|
expect(await notifications(alice.token)).toHaveLength(1);
|
||||||
|
expect((await del(alice.token)).statusCode).toBe(200);
|
||||||
|
expect((await thread(bob.token, listenId)).json().comments).toEqual([]);
|
||||||
|
expect(await notifications(alice.token)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be reported, keeping what it said for the admins', async () => {
|
||||||
|
const { bob, carol, listenId } = await setup();
|
||||||
|
const { id } = (await comment(bob.token, listenId, 'rude words')).json();
|
||||||
|
const report = (token: string) =>
|
||||||
|
app().inject({ method: 'POST', url: `/comments/${id}/report`, headers: bearer(token), payload: { reason: 'harassment' } });
|
||||||
|
expect((await report(bob.token)).statusCode).toBe(400);
|
||||||
|
expect((await report(carol.token)).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes away with a block, both ways', async () => {
|
||||||
|
const { alice, bob, listenId } = await setup();
|
||||||
|
await comment(bob.token, listenId, 'hello');
|
||||||
|
await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} });
|
||||||
|
expect((await thread(bob.token, listenId)).statusCode).toBe(404);
|
||||||
|
expect((await thread(alice.token, listenId)).json().comments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps plays with comments when old history is trimmed', async () => {
|
||||||
|
const { alice, bob, listenId } = await setup();
|
||||||
|
await comment(bob.token, listenId, 'keep this');
|
||||||
|
for (let i = 0; i < LISTEN_HISTORY_LIMIT + 2; i++) {
|
||||||
|
await applyPollResult(alice.user.id, 'lastfm', song(`Song ${i}`));
|
||||||
|
}
|
||||||
|
const res = await thread(bob.token, listenId);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().comments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,20 @@ describe('lyrics', () => {
|
|||||||
expect(body.plain).toBe('the one');
|
expect(body.plain).toBe('the one');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the search when LRCLIB's exact lookup is rate-limited", async () => {
|
||||||
|
const { id } = await upsertTrack({ title: 'Midnight City', artist: 'M83', durationMs: 241_000 });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
routeFetch([
|
||||||
|
[/\/api\/get\?/, () => json({ message: 'slow down' }, 429)],
|
||||||
|
[/\/api\/search\?/, () => json([record()])],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const res = await lyrics(id);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().synced).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null when nothing matches, and 503 when LRCLIB is down', async () => {
|
it('returns null when nothing matches, and 503 when LRCLIB is down', async () => {
|
||||||
const { id } = await upsertTrack({ title: 'Unknown', artist: 'Nobody', durationMs: 100_000 });
|
const { id } = await upsertTrack({ title: 'Unknown', artist: 'Nobody', durationMs: 100_000 });
|
||||||
vi.stubGlobal('fetch', routeFetch([[/lrclib/, () => json({ message: 'down' }, 500)]]));
|
vi.stubGlobal('fetch', routeFetch([[/lrclib/, () => json({ message: 'down' }, 500)]]));
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ describe('notifications', () => {
|
|||||||
|
|
||||||
it('saves notification settings', async () => {
|
it('saves notification settings', async () => {
|
||||||
const { alice } = await setup();
|
const { alice } = await setup();
|
||||||
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } })).json();
|
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false, reply: false } } })).json();
|
||||||
expect(me.notifyPrefs).toEqual({ reaction: true, follow: false, share: true });
|
expect(me.notifyPrefs).toEqual({ reaction: true, comment: true, reply: false, follow: false, share: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "musicshare",
|
"name": "musicshare",
|
||||||
"slug": "musicshare",
|
"slug": "musicshare",
|
||||||
"version": "1.2.0",
|
"version": "1.4.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "musicshare",
|
"scheme": "musicshare",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "tech.chrisedwards.musicshare",
|
"package": "tech.chrisedwards.musicshare",
|
||||||
"versionCode": 3
|
"versionCode": 5
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#0b0820",
|
"backgroundColor": "#2a2942",
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"imageWidth": 150
|
"imageWidth": 150
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@musicshare/mobile",
|
"name": "@musicshare/mobile",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.2.0",
|
"version": "1.4.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
"@expo-google-fonts/poppins": "^0.4.1",
|
||||||
"@expo/ui": "~57.0.21",
|
"@expo/ui": "~57.0.21",
|
||||||
"@musicshare/shared": "*",
|
"@musicshare/shared": "*",
|
||||||
"expo": "~57.0.26",
|
"expo": "~57.0.26",
|
||||||
@@ -12,10 +12,12 @@
|
|||||||
"expo-camera": "~57.0.6",
|
"expo-camera": "~57.0.6",
|
||||||
"expo-constants": "~57.0.20",
|
"expo-constants": "~57.0.20",
|
||||||
"expo-device": "~57.0.2",
|
"expo-device": "~57.0.2",
|
||||||
|
"expo-file-system": "~57.0.7",
|
||||||
"expo-font": "~57.0.4",
|
"expo-font": "~57.0.4",
|
||||||
"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-manipulator": "~57.0.21",
|
||||||
"expo-image-picker": "~57.0.20",
|
"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",
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ export default function TabsLayout() {
|
|||||||
return (
|
return (
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
backgroundColor={colors.bg}
|
backgroundColor={colors.bg}
|
||||||
indicatorColor={colors.surface2}
|
// The active tab gets a pink pill, like the reference's sidebar.
|
||||||
|
indicatorColor={colors.accentSoft}
|
||||||
// Android defaults icons and labels to black, which vanishes on the dark bar.
|
// Android defaults icons and labels to black, which vanishes on the dark bar.
|
||||||
iconColor={{ default: colors.muted, selected: colors.text }}
|
iconColor={{ default: colors.muted, selected: colors.accent }}
|
||||||
labelStyle={{ default: { color: colors.muted }, selected: { color: colors.text } }}
|
labelStyle={{ default: { color: colors.muted }, selected: { color: colors.accent } }}
|
||||||
labelVisibilityMode="labeled"
|
labelVisibilityMode="labeled"
|
||||||
tintColor={colors.accent}>
|
tintColor={colors.accent}>
|
||||||
<NativeTabs.Trigger name="index">
|
<NativeTabs.Trigger name="index">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import Animated, { FadeIn, LinearTransition } from 'react-native-reanimated';
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import useSWR, { useSWRConfig } from 'swr';
|
import useSWR, { useSWRConfig } from 'swr';
|
||||||
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||||
@@ -85,9 +85,9 @@ export default function FeedScreen() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
{/* Cards fade in and slide to new positions when someone pins or starts playing. */}
|
{/* No itemLayoutAnimation: it leaves the cards behind when the header above them changes height
|
||||||
|
(e.g. your own card's lyrics loading), opening a gap under "Friends listening". */}
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
itemLayoutAnimation={LinearTransition.duration(200)}
|
|
||||||
data={feed ?? []}
|
data={feed ?? []}
|
||||||
keyExtractor={(item) => item.user.id}
|
keyExtractor={(item) => item.user.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
@@ -95,7 +95,19 @@ export default function FeedScreen() {
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.topBar}>
|
<View style={styles.topBar}>
|
||||||
|
{me ? (
|
||||||
|
<Pressable style={styles.greeting} onPress={() => router.push('/profile')} accessibilityRole="link">
|
||||||
|
<Avatar user={me} size={44} />
|
||||||
|
<View style={{ flexShrink: 1 }}>
|
||||||
|
<Text style={styles.hi} numberOfLines={1}>
|
||||||
|
Hi {me.displayName ?? me.username},
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.welcome}>Welcome back</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
<Text style={styles.brand}>musicshare</Text>
|
<Text style={styles.brand}>musicshare</Text>
|
||||||
|
)}
|
||||||
<ActivityBell />
|
<ActivityBell />
|
||||||
</View>
|
</View>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
@@ -115,7 +127,7 @@ export default function FeedScreen() {
|
|||||||
)}
|
)}
|
||||||
{mine?.nowPlaying && (
|
{mine?.nowPlaying && (
|
||||||
<View style={{ gap: space.sm }}>
|
<View style={{ gap: space.sm }}>
|
||||||
<Text style={styles.sectionLabel}>YOU</Text>
|
<Text style={styles.sectionLabel}>Your song</Text>
|
||||||
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -143,7 +155,7 @@ export default function FeedScreen() {
|
|||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
quiet.length > 0 ? (
|
quiet.length > 0 ? (
|
||||||
<View style={{ gap: space.sm, marginTop: space.md }}>
|
<View style={{ gap: space.sm, marginTop: space.md }}>
|
||||||
<Text style={styles.sectionLabel}>FOLLOWING · NOTHING PLAYED YET</Text>
|
<Text style={styles.sectionLabel}>Following · nothing played yet</Text>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.quiet}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.quiet}>
|
||||||
{quiet.map((u) => (
|
{quiet.map((u) => (
|
||||||
<Pressable key={u.id} onPress={() => router.push(`/u/${u.username}`)} style={styles.quietPerson} accessibilityLabel={u.displayName ?? u.username}>
|
<Pressable key={u.id} onPress={() => router.push(`/u/${u.username}`)} style={styles.quietPerson} accessibilityLabel={u.displayName ?? u.username}>
|
||||||
@@ -183,8 +195,11 @@ const styles = StyleSheet.create({
|
|||||||
header: { gap: space.md, marginBottom: space.xs },
|
header: { gap: space.md, marginBottom: space.xs },
|
||||||
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 },
|
brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 },
|
||||||
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 },
|
greeting: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
|
||||||
|
hi: { color: colors.text, fontFamily: fonts.display, fontSize: 19 },
|
||||||
|
welcome: { color: colors.muted, fontFamily: fonts.body, fontSize: 13 },
|
||||||
|
sectionLabel: { color: colors.muted, fontSize: 13, fontFamily: fonts.bodyMedium },
|
||||||
quiet: { gap: space.lg, paddingVertical: space.xs },
|
quiet: { gap: space.lg, paddingVertical: space.xs },
|
||||||
quietPerson: { width: 64, alignItems: 'center', gap: 4 },
|
quietPerson: { width: 64, alignItems: 'center', gap: 4 },
|
||||||
quietName: { color: colors.text, fontSize: 12, textAlign: 'center' },
|
quietName: { color: colors.text, fontSize: 11.5, fontFamily: fonts.body, textAlign: 'center' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { PersonRow, emptyFollowText, useFollowList } from '@/components/FollowLi
|
|||||||
import { Body, Button, Chip, Field, Row, Title } from '@/components/ui';
|
import { Body, Button, Chip, Field, Row, Title } from '@/components/ui';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { useDebounced } from '@/lib/useDebounced';
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
|
|
||||||
/** Friends: who you follow and who follows you, plus search and QR codes to find new people. */
|
/** Friends: who you follow and who follows you, plus search and QR codes to find new people. */
|
||||||
export default function FriendsScreen() {
|
export default function FriendsScreen() {
|
||||||
@@ -95,6 +95,6 @@ const styles = StyleSheet.create({
|
|||||||
safe: { flex: 1, backgroundColor: colors.bg },
|
safe: { flex: 1, backgroundColor: colors.bg },
|
||||||
list: { padding: space.lg, gap: space.md },
|
list: { padding: space.lg, gap: space.md },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.sm },
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.sm },
|
||||||
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
name: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||||
handle: { color: colors.muted, fontSize: 13 },
|
handle: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { File } from 'expo-file-system';
|
||||||
|
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
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';
|
||||||
@@ -31,7 +33,7 @@ import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
|||||||
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
import { checkForUpdate, currentVersion, installUpdate, updaterAvailable, useUpdateState } from '@/lib/updates';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
return (
|
return (
|
||||||
@@ -103,10 +105,18 @@ function AvatarPicker() {
|
|||||||
});
|
});
|
||||||
const asset = result.canceled ? null : result.assets[0];
|
const asset = result.canceled ? null : result.assets[0];
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
|
run(async () => {
|
||||||
|
// Shrink first: photos from 50-200 MP cameras are huge, and the server keeps 512 px anyway.
|
||||||
|
const side = Math.min(1024, asset.width || 1024);
|
||||||
|
const image = await (await ImageManipulator.manipulate(asset.uri).resize({ width: side }).renderAsync()).saveAsync({
|
||||||
|
compress: 0.85,
|
||||||
|
format: SaveFormat.JPEG,
|
||||||
|
});
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
// React Native's FormData takes a file as { uri, name, type }.
|
// Expo's fetch needs a real file object (it rejects React Native's { uri, name, type } parts).
|
||||||
form.append('file', { uri: asset.uri, name: asset.fileName ?? 'avatar.jpg', type: asset.mimeType ?? 'image/jpeg' } as unknown as Blob);
|
form.append('file', new File(image.uri) as unknown as Blob);
|
||||||
run(() => api.uploadAvatar(form));
|
return api.uploadAvatar(form);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = () =>
|
const remove = () =>
|
||||||
@@ -660,7 +670,7 @@ function Account() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
small: { fontSize: 13 },
|
small: { fontSize: 13, fontFamily: fonts.body },
|
||||||
avatarRow: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
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: {
|
||||||
@@ -670,6 +680,6 @@ const styles = StyleSheet.create({
|
|||||||
paddingTop: space.md,
|
paddingTop: space.md,
|
||||||
},
|
},
|
||||||
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
||||||
resultText: { color: colors.text, flex: 1, fontSize: 14 },
|
resultText: { color: colors.text, flex: 1, fontSize: 14, fontFamily: fonts.body },
|
||||||
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SpaceGrotesk_500Medium, SpaceGrotesk_700Bold, useFonts } from '@expo-google-fonts/space-grotesk';
|
import { Poppins_400Regular, Poppins_500Medium, Poppins_600SemiBold, useFonts } from '@expo-google-fonts/poppins';
|
||||||
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
|
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
|
||||||
import * as SplashScreen from 'expo-splash-screen';
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
@@ -37,7 +37,7 @@ function AppNavigator() {
|
|||||||
const { status, me, retry } = useSession();
|
const { status, me, retry } = useSession();
|
||||||
const signedIn = status === 'signedIn';
|
const signedIn = status === 'signedIn';
|
||||||
const onboarded = Boolean(me?.onboardedAt);
|
const onboarded = Boolean(me?.onboardedAt);
|
||||||
const [fontsLoaded, fontError] = useFonts({ SpaceGrotesk_500Medium, SpaceGrotesk_700Bold });
|
const [fontsLoaded, fontError] = useFonts({ Poppins_400Regular, Poppins_500Medium, Poppins_600SemiBold });
|
||||||
const ready = status !== 'loading' && (fontsLoaded || Boolean(fontError));
|
const ready = status !== 'loading' && (fontsLoaded || Boolean(fontError));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -109,6 +109,7 @@ function AppNavigator() {
|
|||||||
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
||||||
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
|
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
|
||||||
<Stack.Screen name="t/[id]" options={{ title: '' }} />
|
<Stack.Screen name="t/[id]" options={{ title: '' }} />
|
||||||
|
<Stack.Screen name="play/[id]" options={{ title: 'Comments' }} />
|
||||||
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
|
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Protected guard={!signedIn}>
|
<Stack.Protected guard={!signedIn}>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Avatar } from '@/components/Avatar';
|
|||||||
import { Body, Button } from '@/components/ui';
|
import { Body, Button } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
|
|
||||||
export default function BlockedScreen() {
|
export default function BlockedScreen() {
|
||||||
const { data: blocked = [], mutate, isLoading } = useSWR<UserSummaryDTO[]>('/me/blocks');
|
const { data: blocked = [], mutate, isLoading } = useSWR<UserSummaryDTO[]>('/me/blocks');
|
||||||
@@ -45,5 +45,5 @@ const styles = StyleSheet.create({
|
|||||||
list: { padding: space.lg, gap: space.md },
|
list: { padding: space.lg, gap: space.md },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
name: { color: colors.text, fontSize: 15, fontWeight: '600', flexShrink: 1 },
|
name: { color: colors.text, fontSize: 15, fontFamily: fonts.bodyMedium, flexShrink: 1 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { notificationPath, notificationText, timeAgo, type NotificationDTO, type
|
|||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { Body } from '@/components/ui';
|
import { Body } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
|
|
||||||
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||||
export default function NotificationsScreen() {
|
export default function NotificationsScreen() {
|
||||||
@@ -77,8 +77,8 @@ const styles = StyleSheet.create({
|
|||||||
row: { flexDirection: 'row', gap: space.md, alignItems: 'center', padding: space.md, borderRadius: 14 },
|
row: { flexDirection: 'row', gap: space.md, alignItems: 'center', padding: space.md, borderRadius: 14 },
|
||||||
unread: { backgroundColor: colors.surface2 },
|
unread: { backgroundColor: colors.surface2 },
|
||||||
text: { flex: 1, minWidth: 0, gap: 2 },
|
text: { flex: 1, minWidth: 0, gap: 2 },
|
||||||
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
title: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||||
body: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
body: { color: colors.text, opacity: 0.85, fontSize: 14, fontFamily: fonts.body },
|
||||||
time: { color: colors.muted, fontSize: 12 },
|
time: { color: colors.muted, fontSize: 12 },
|
||||||
art: { width: 44, height: 44, borderRadius: 8 },
|
art: { width: 44, height: 44, borderRadius: 8 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { WEB_URL } from '@/lib/config';
|
|||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { successHaptic } from '@/lib/haptics';
|
import { successHaptic } from '@/lib/haptics';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
import { useDebounced } from '@/lib/useDebounced';
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
|
|
||||||
/** Step 3: an empty feed is the fastest way to lose someone, so find friends now. */
|
/** Step 3: an empty feed is the fastest way to lose someone, so find friends now. */
|
||||||
@@ -90,6 +90,6 @@ export default function FriendsStep() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
|
||||||
who: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
|
who: { flexDirection: 'row', alignItems: 'center', gap: space.md, flexShrink: 1 },
|
||||||
name: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
name: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||||
handle: { color: colors.muted, fontSize: 13 },
|
handle: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { showError } from '@/lib/errors';
|
|||||||
import { selectionHaptic } from '@/lib/haptics';
|
import { selectionHaptic } from '@/lib/haptics';
|
||||||
import { LISTEN_APPS, finishOnboarding } from '@/lib/onboarding';
|
import { LISTEN_APPS, finishOnboarding } from '@/lib/onboarding';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
/** Step 1: where do you listen? Sets where friends' songs open, and picks the right setup next. */
|
/** Step 1: where do you listen? Sets where friends' songs open, and picks the right setup next. */
|
||||||
export default function ListenAppStep() {
|
export default function ListenAppStep() {
|
||||||
@@ -69,7 +69,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: space.md,
|
paddingHorizontal: space.md,
|
||||||
},
|
},
|
||||||
optionPicked: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.18)' },
|
optionPicked: { borderColor: colors.accent, backgroundColor: colors.accentSoft },
|
||||||
optionText: { color: colors.text, fontWeight: '600', fontSize: 15 },
|
optionText: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 15 },
|
||||||
optionTextPicked: { color: '#fff' },
|
optionTextPicked: { color: '#fff' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
|
||||||
|
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { Reactions } from '@/components/Reactions';
|
||||||
|
import { TrackCard } from '@/components/TrackCard';
|
||||||
|
import { Body, Card } from '@/components/ui';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { showError } from '@/lib/errors';
|
||||||
|
import { tapHaptic } from '@/lib/haptics';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
|
/** One play and its comments: where the 💬 chip and comment notifications lead. */
|
||||||
|
export default function PlayScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { me } = useSession();
|
||||||
|
// Polled while open so other people's comments show up without refreshing.
|
||||||
|
const { data, error, mutate } = useSWR<CommentThreadDTO>(id ? `/listens/${id}/comments` : null, { refreshInterval: 5000 });
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body || !id) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const comment = await api.addComment(id, { body });
|
||||||
|
tapHaptic();
|
||||||
|
setDraft('');
|
||||||
|
await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Couldn't post that");
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (c: CommentDTO) =>
|
||||||
|
Alert.alert('Delete this comment?', undefined, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await api.deleteComment(c.id);
|
||||||
|
await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
showError(err, "Couldn't delete it");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const options = (c: CommentDTO) => {
|
||||||
|
const mine = c.user.id === me?.id;
|
||||||
|
Alert.alert(c.user.displayName ?? c.user.username, undefined, [
|
||||||
|
...(c.canDelete ? [{ text: 'Delete', style: 'destructive' as const, onPress: () => remove(c) }] : []),
|
||||||
|
...(!mine ? [{ text: 'Report', onPress: () => router.push(`/report/${c.user.username}?comment=${c.id}`) }] : []),
|
||||||
|
{ text: 'Cancel', style: 'cancel' as const },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Stack.Screen options={{ title: '' }} />
|
||||||
|
<Body muted>That play is gone.</Body>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator color={colors.muted} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = data.owner.displayName ?? data.owner.username;
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||||
|
<Stack.Screen options={{ title: 'Comments' }} />
|
||||||
|
<AmbientBackground />
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={90}>
|
||||||
|
<FlatList
|
||||||
|
data={data.comments}
|
||||||
|
keyExtractor={(c) => c.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable onPress={() => router.push(`/u/${data.owner.username}`)} style={styles.playedBy}>
|
||||||
|
<Avatar user={data.owner} size={28} />
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
<Text style={styles.name}>{owner}</Text> played this · {timeAgo(data.playedAt)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<TrackCard track={data.track} preferred={me?.preferredService} />
|
||||||
|
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} style={{ marginTop: 0 }} />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={<Body muted>{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</Body>}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable onLongPress={() => options(item)} delayLongPress={300} style={styles.comment}>
|
||||||
|
<Pressable onPress={() => router.push(`/u/${item.user.username}`)} hitSlop={4}>
|
||||||
|
<Avatar user={item.user} size={34} />
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
<Text style={styles.name}>{item.user.displayName ?? item.user.username}</Text> · {timeAgo(item.createdAt)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.body} selectable>
|
||||||
|
{item.body}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => options(item)} hitSlop={10} accessibilityLabel="Comment options">
|
||||||
|
<Text style={styles.more}>⋯</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{data.canComment ? (
|
||||||
|
<View style={styles.composer}>
|
||||||
|
<TextInput
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
|
||||||
|
placeholderTextColor={colors.muted}
|
||||||
|
multiline
|
||||||
|
maxLength={COMMENT_MAX_LENGTH}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={send}
|
||||||
|
disabled={!draft.trim() || sending}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Post comment"
|
||||||
|
style={[styles.send, (!draft.trim() || sending) && { opacity: 0.4 }]}>
|
||||||
|
{sending ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.sendText}>Post</Text>}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Card style={styles.closed}>
|
||||||
|
<Body muted style={{ fontSize: 13 }}>
|
||||||
|
Only people {owner} follows can comment on their songs.
|
||||||
|
</Body>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
|
||||||
|
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl },
|
||||||
|
header: { gap: space.md, marginBottom: space.sm },
|
||||||
|
playedBy: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
||||||
|
comment: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
|
||||||
|
name: { color: colors.text, fontFamily: fonts.display },
|
||||||
|
muted: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||||
|
body: { color: colors.text, fontSize: 15, lineHeight: 21, fontFamily: fonts.body },
|
||||||
|
more: { color: colors.muted, fontSize: 18, paddingHorizontal: space.xs },
|
||||||
|
composer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: space.sm,
|
||||||
|
paddingHorizontal: space.lg,
|
||||||
|
paddingVertical: space.sm,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
maxHeight: 120,
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 15,
|
||||||
|
paddingHorizontal: space.md,
|
||||||
|
paddingVertical: space.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
send: { paddingHorizontal: space.md, paddingVertical: space.sm + 2, borderRadius: radius.pill, backgroundColor: colors.accent },
|
||||||
|
sendText: { color: '#fff', fontFamily: fonts.display },
|
||||||
|
closed: { margin: space.lg, marginTop: 0 },
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import { Screen } from '@/components/Screen';
|
|||||||
import { Body, Button } from '@/components/ui';
|
import { Body, Button } from '@/components/ui';
|
||||||
import { WEB_URL } from '@/lib/config';
|
import { WEB_URL } from '@/lib/config';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
|
|
||||||
/** "My code": friends scan it (in musicshare or any camera app) to open your profile. */
|
/** "My code": friends scan it (in musicshare or any camera app) to open your profile. */
|
||||||
export default function MyQrScreen() {
|
export default function MyQrScreen() {
|
||||||
@@ -40,8 +40,8 @@ export default function MyQrScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
content: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', gap: space.xl },
|
content: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', gap: space.xl },
|
||||||
who: { alignItems: 'center', gap: space.xs },
|
who: { alignItems: 'center', gap: space.xs },
|
||||||
name: { color: colors.text, fontSize: 22, fontWeight: '800' },
|
name: { color: colors.text, fontSize: 22, fontFamily: fonts.display },
|
||||||
handle: { color: colors.muted, fontSize: 15 },
|
handle: { color: colors.muted, fontSize: 15, fontFamily: fonts.body },
|
||||||
hint: { textAlign: 'center', maxWidth: 300 },
|
hint: { textAlign: 'center', maxWidth: 300 },
|
||||||
actions: { alignSelf: 'stretch', gap: space.sm },
|
actions: { alignSelf: 'stretch', gap: space.sm },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { api } from '@/lib/api';
|
|||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
|
|
||||||
export default function ReportScreen() {
|
export default function ReportScreen() {
|
||||||
const { username } = useLocalSearchParams<{ username: string }>();
|
// ?comment=<id> reports one comment of theirs rather than their account.
|
||||||
|
const { username, comment } = useLocalSearchParams<{ username: string; comment?: string }>();
|
||||||
const [reason, setReason] = useState<ReportReason | null>(null);
|
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -18,7 +19,8 @@ export default function ReportScreen() {
|
|||||||
if (!reason) return;
|
if (!reason) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.report(username, { reason, note: note.trim() || null });
|
const input = { reason, note: note.trim() || null };
|
||||||
|
await (comment ? api.reportComment(comment, input) : api.report(username, input));
|
||||||
setSent(true);
|
setSent(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err, "Couldn't send the report");
|
showError(err, "Couldn't send the report");
|
||||||
@@ -38,7 +40,7 @@ export default function ReportScreen() {
|
|||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
<Screen>
|
<Screen>
|
||||||
<Body muted>What's wrong with @{username}'s account?</Body>
|
<Body muted>{comment ? `What's wrong with @${username}'s comment?` : `What's wrong with @${username}'s account?`}</Body>
|
||||||
<Row>
|
<Row>
|
||||||
{REPORT_REASONS.map((r) => (
|
{REPORT_REASONS.map((r) => (
|
||||||
<Chip key={r} label={REPORT_REASON_LABELS[r]} selected={reason === r} onPress={() => setReason(r)} />
|
<Chip key={r} label={REPORT_REASON_LABELS[r]} selected={reason === r} onPress={() => setReason(r)} />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Body, Button, Title } from '@/components/ui';
|
|||||||
import { FOSS_BUILD } from '@/lib/build';
|
import { FOSS_BUILD } from '@/lib/build';
|
||||||
import { decodeQrFromJpeg, pickSnapshotSize } from '@/lib/qrSnapshot';
|
import { decodeQrFromJpeg, pickSnapshotSize } from '@/lib/qrSnapshot';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
/** Scan a friend's musicshare QR code and open their profile (with its Follow button). */
|
/** Scan a friend's musicshare QR code and open their profile (with its Follow button). */
|
||||||
export default function ScanScreen() {
|
export default function ScanScreen() {
|
||||||
@@ -115,7 +115,7 @@ const styles = StyleSheet.create({
|
|||||||
prompt: {
|
prompt: {
|
||||||
color: colors.accentText,
|
color: colors.accentText,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontFamily: fonts.bodyMedium,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
paddingHorizontal: space.md,
|
paddingHorizontal: space.md,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { api } from '@/lib/api';
|
|||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
/** Send a song to some of your followers, with an optional note. */
|
/** Send a song to some of your followers, with an optional note. */
|
||||||
export default function SendSongScreen() {
|
export default function SendSongScreen() {
|
||||||
@@ -98,10 +98,10 @@ export default function SendSongScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
list: { padding: space.lg, gap: space.sm, paddingBottom: space.xl * 2 },
|
list: { padding: space.lg, gap: space.sm, paddingBottom: space.xl * 2 },
|
||||||
song: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
song: { color: colors.text, fontSize: 17, fontFamily: fonts.display },
|
||||||
muted: { color: colors.muted, fontWeight: '400' },
|
muted: { color: colors.muted, fontFamily: fonts.body },
|
||||||
person: { flexDirection: 'row', alignItems: 'center', gap: space.md, padding: space.sm, borderRadius: radius.md },
|
person: { flexDirection: 'row', alignItems: 'center', gap: space.md, padding: space.sm, borderRadius: radius.md },
|
||||||
on: { backgroundColor: colors.surface2 },
|
on: { backgroundColor: colors.surface2 },
|
||||||
name: { flex: 1, color: colors.text, fontSize: 15, fontWeight: '600' },
|
name: { flex: 1, color: colors.text, fontSize: 15, fontFamily: fonts.bodyMedium },
|
||||||
check: { color: colors.muted, fontSize: 18 },
|
check: { color: colors.muted, fontSize: 18, fontFamily: fonts.body },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import type { NotificationListDTO } from '@musicshare/shared';
|
import type { NotificationListDTO } from '@musicshare/shared';
|
||||||
import { colors } from '@/lib/theme';
|
import { colors, fonts } from '@/lib/theme';
|
||||||
|
|
||||||
/** Bell with the unread count; opens your notifications. */
|
/** Bell with the unread count; opens your notifications. */
|
||||||
export function ActivityBell() {
|
export function ActivityBell() {
|
||||||
@@ -15,7 +16,10 @@ export function ActivityBell() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={unread ? `Notifications, ${unread} new` : 'Notifications'}
|
accessibilityLabel={unread ? `Notifications, ${unread} new` : 'Notifications'}
|
||||||
style={styles.bell}>
|
style={styles.bell}>
|
||||||
<Text style={styles.icon}>🔔</Text>
|
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<Path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</Svg>
|
||||||
{unread > 0 && (
|
{unread > 0 && (
|
||||||
<View style={styles.badge}>
|
<View style={styles.badge}>
|
||||||
<Text style={styles.badgeText}>{unread > 99 ? '99+' : unread}</Text>
|
<Text style={styles.badgeText}>{unread > 99 ? '99+' : unread}</Text>
|
||||||
@@ -26,8 +30,7 @@ export function ActivityBell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
bell: { width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.surface2 },
|
bell: { width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.surface2 },
|
||||||
icon: { fontSize: 18 },
|
|
||||||
badge: {
|
badge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -2,
|
top: -2,
|
||||||
@@ -36,9 +39,9 @@ const styles = StyleSheet.create({
|
|||||||
height: 18,
|
height: 18,
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
borderRadius: 9,
|
borderRadius: 9,
|
||||||
backgroundColor: colors.danger,
|
backgroundColor: colors.accent,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
badgeText: { color: '#fff', fontSize: 11, fontFamily: fonts.display },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,15 @@
|
|||||||
import { Image } from 'expo-image';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useEffect } from 'react';
|
import { StyleSheet } from 'react-native';
|
||||||
import { StyleSheet, useWindowDimensions, View } from 'react-native';
|
|
||||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
|
||||||
import { colors } from '@/lib/theme';
|
import { colors } from '@/lib/theme';
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
/** The page behind everything: flat slate, a touch lighter at the top. */
|
||||||
|
|
||||||
/** Two soft colour blobs drifting slowly behind the content, so screens never feel static. */
|
|
||||||
export function AmbientBackground() {
|
export function AmbientBackground() {
|
||||||
const { width, height } = useWindowDimensions();
|
|
||||||
const t = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
t.set(withRepeat(withTiming(1, { duration: 18000, easing: Easing.inOut(Easing.sin) }), -1, true));
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const size = Math.max(width, height) * 0.9;
|
|
||||||
const purple = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateX: -size * 0.35 + t.value * width * 0.35 }, { translateY: -size * 0.3 + t.value * height * 0.12 }],
|
|
||||||
}));
|
|
||||||
const blue = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ translateX: width - size * 0.6 - t.value * width * 0.3 }, { translateY: height - size * 0.55 - t.value * height * 0.1 }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.bg }]} pointerEvents="none">
|
<LinearGradient
|
||||||
<AnimatedImage source={require('@/assets/images/ambient-purple.png')} style={[styles.blob, { width: size, height: size }, purple]} />
|
colors={['#32304e', colors.bg, colors.bg]}
|
||||||
<AnimatedImage source={require('@/assets/images/ambient-blue.png')} style={[styles.blob, { width: size, height: size }, blue]} />
|
locations={[0, 0.45, 1]}
|
||||||
</View>
|
style={[StyleSheet.absoluteFill, { backgroundColor: colors.bg }]}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
blob: { position: 'absolute', top: 0, left: 0, opacity: 0.45 },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { KeyboardAvoidingView, Linking, Platform, StyleSheet, Text, View } from
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { WEB_URL } from '@/lib/config';
|
import { WEB_URL } from '@/lib/config';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
import { Screen } from './Screen';
|
import { Screen } from './Screen';
|
||||||
import { Body, Button, Field, Title } from './ui';
|
import { Body, Button, Field, Title } from './ui';
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
content: { flexGrow: 1, justifyContent: 'center' },
|
content: { flexGrow: 1, justifyContent: 'center' },
|
||||||
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
|
link: { color: colors.muted, textAlign: 'center', paddingVertical: space.sm },
|
||||||
legal: { color: colors.muted, fontSize: 12, textAlign: 'center', lineHeight: 17 },
|
legal: { color: colors.muted, fontSize: 12, textAlign: 'center', lineHeight: 17, fontFamily: fonts.body },
|
||||||
legalLink: { color: colors.text, textDecorationLine: 'underline' },
|
legalLink: { color: colors.text, textDecorationLine: 'underline' },
|
||||||
logo: { width: 76, height: 76, borderRadius: 20, marginBottom: space.sm },
|
logo: { width: 76, height: 76, borderRadius: 20, marginBottom: space.sm },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function Avatar({ user, size = 40, live }: { user: UserSummaryDTO; size?:
|
|||||||
const face = user.avatarUrl ? (
|
const face = user.avatarUrl ? (
|
||||||
<Image source={user.avatarUrl} style={shape} contentFit="cover" />
|
<Image source={user.avatarUrl} style={shape} contentFit="cover" />
|
||||||
) : (
|
) : (
|
||||||
<LinearGradient colors={['#4a1278', '#1872cf']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={[styles.empty, shape]}>
|
<LinearGradient colors={[colors.accent, '#8b5cf6']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={[styles.empty, shape]}>
|
||||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
);
|
);
|
||||||
@@ -31,7 +31,7 @@ export function Avatar({ user, size = 40, live }: { user: UserSummaryDTO; size?:
|
|||||||
<View style={{ width: ring, height: ring, alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ width: ring, height: ring, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, spin]}>
|
<Animated.View style={[StyleSheet.absoluteFill, spin]}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#7b2ff7', '#2f8cff', colors.live, '#7b2ff7']}
|
colors={[colors.accent, colors.accent2, colors.live, colors.accent]}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
style={{ width: ring, height: ring, borderRadius: ring / 2 }}
|
style={{ width: ring, height: ring, borderRadius: ring / 2 }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import useSWR from 'swr';
|
|||||||
import type { ProfileDTO, TrackSearchResultDTO } from '@musicshare/shared';
|
import type { ProfileDTO, TrackSearchResultDTO } from '@musicshare/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { useDebounced } from '@/lib/useDebounced';
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
import { Body, Button, Card, Field, Heading, Row } from './ui';
|
import { Body, Button, Card, Field, Heading, Row } from './ui';
|
||||||
@@ -147,9 +147,9 @@ export function FeaturedPicker({ bare, onSaved }: { bare?: boolean; onSaved?: ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
small: { fontSize: 13 },
|
small: { fontSize: 13, fontFamily: fonts.body },
|
||||||
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
title: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
|
||||||
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
result: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: space.xs },
|
||||||
resultText: { color: colors.text, flex: 1, fontSize: 14 },
|
resultText: { color: colors.text, flex: 1, fontSize: 14, fontFamily: fonts.body },
|
||||||
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
thumb: { width: 40, height: 40, borderRadius: radius.sm },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { FollowListDTO, FollowListItemDTO } from '@musicshare/shared';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { Body, Button } from './ui';
|
import { Body, Button } from './ui';
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ const styles = StyleSheet.create({
|
|||||||
list: { padding: space.lg, gap: space.md },
|
list: { padding: space.lg, gap: space.md },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
person: { flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
name: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
name: { color: colors.text, fontSize: 15, fontFamily: fonts.bodyMedium },
|
||||||
handle: { color: colors.muted, fontSize: 13 },
|
handle: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||||
actions: { flexDirection: 'row', gap: space.sm },
|
actions: { flexDirection: 'row', gap: space.sm },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
|||||||
import Animated, { LinearTransition } from 'react-native-reanimated';
|
import Animated, { LinearTransition } from 'react-native-reanimated';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { currentLyricIndex, playbackPosition, type LyricLine, type LyricsDTO, type NowPlayingDTO, type TrackDTO } from '@musicshare/shared';
|
import { currentLyricIndex, playbackPosition, type LyricLine, type LyricsDTO, type NowPlayingDTO, type TrackDTO } from '@musicshare/shared';
|
||||||
import { colors, fonts, space } from '@/lib/theme';
|
import { colors, fonts, space, useInk } from '@/lib/theme';
|
||||||
|
|
||||||
/** Lines shown around the current one while following along. */
|
/** Lines shown around the current one while following along. */
|
||||||
const BEFORE = 1;
|
const BEFORE = 1;
|
||||||
@@ -16,13 +16,15 @@ const PREVIEW = 8;
|
|||||||
* lines around the one being sung and follows along; "All lyrics" shows the rest.
|
* lines around the one being sung and follows along; "All lyrics" shows the rest.
|
||||||
*/
|
*/
|
||||||
export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
||||||
|
const ink = useInk();
|
||||||
|
const note = [styles.note, { color: ink.muted }];
|
||||||
const { data, error, isLoading } = useSWR<LyricsDTO | null>(`/tracks/${track.id}/lyrics`);
|
const { data, error, isLoading } = useSWR<LyricsDTO | null>(`/tracks/${track.id}/lyrics`);
|
||||||
const [full, setFull] = useState(false);
|
const [full, setFull] = useState(false);
|
||||||
|
|
||||||
if (isLoading) return <Text style={styles.note}>Finding lyrics…</Text>;
|
if (isLoading) return <Text style={note}>Finding lyrics…</Text>;
|
||||||
if (error) return <Text style={styles.note}>Couldn't load lyrics right now.</Text>;
|
if (error) return <Text style={note}>Couldn't load lyrics right now.</Text>;
|
||||||
if (!data) return <Text style={styles.note}>No lyrics found for this song.</Text>;
|
if (!data) return <Text style={note}>No lyrics found for this song.</Text>;
|
||||||
if (data.instrumental && !data.synced && !data.plain) return <Text style={styles.note}>♪ Instrumental</Text>;
|
if (data.instrumental && !data.synced && !data.plain) return <Text style={note}>♪ Instrumental</Text>;
|
||||||
|
|
||||||
const synced = data.synced;
|
const synced = data.synced;
|
||||||
const live = Boolean(np?.isPlaying && playbackPosition(np, 0));
|
const live = Boolean(np?.isPlaying && playbackPosition(np, 0));
|
||||||
@@ -48,11 +50,11 @@ export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
{canToggle && (
|
{canToggle && (
|
||||||
<Text style={styles.toggle} onPress={() => setFull((v) => !v)}>
|
<Text style={[styles.toggle, ink.hero && { color: '#ffffff' }]} onPress={() => setFull((v) => !v)}>
|
||||||
{!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'}
|
{!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.source} onPress={() => Linking.openURL(data.source.url)}>
|
<Text style={[styles.source, { color: ink.muted }]} onPress={() => Linking.openURL(data.source.url)}>
|
||||||
Lyrics from {data.source.name}
|
Lyrics from {data.source.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -85,13 +87,13 @@ function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { gap: space.sm },
|
wrap: { gap: space.sm },
|
||||||
note: { color: colors.muted, fontSize: 13 },
|
note: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||||
line: { color: colors.text, fontSize: 15, lineHeight: 22 },
|
line: { color: colors.text, fontSize: 14.5, lineHeight: 22, fontFamily: fonts.body },
|
||||||
plain: { opacity: 0.9 },
|
plain: { opacity: 0.9 },
|
||||||
past: { opacity: 0.4 },
|
past: { opacity: 0.4 },
|
||||||
now: { fontFamily: fonts.display, fontSize: 17, lineHeight: 24 },
|
now: { fontFamily: fonts.display, fontSize: 17, lineHeight: 24 },
|
||||||
next: { opacity: 0.65 },
|
next: { opacity: 0.65 },
|
||||||
footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
toggle: { color: colors.accent2, fontSize: 13, fontWeight: '600' },
|
toggle: { color: colors.accent, fontSize: 13, fontFamily: fonts.bodyMedium },
|
||||||
source: { color: colors.muted, fontSize: 11, marginLeft: 'auto' },
|
source: { color: colors.muted, fontSize: 11, fontFamily: fonts.body, marginLeft: 'auto' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Image } from 'expo-image';
|
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
@@ -13,7 +12,7 @@ import Animated, {
|
|||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
|
||||||
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { OnHero, colors, fonts, heroGradient, panelShadow, radius, space } from '@/lib/theme';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { Equalizer } from './Equalizer';
|
import { Equalizer } from './Equalizer';
|
||||||
import { Lyrics } from './Lyrics';
|
import { Lyrics } from './Lyrics';
|
||||||
@@ -54,15 +53,12 @@ export function NowPlayingCard({
|
|||||||
onTogglePin?.();
|
onTogglePin?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Your own song gets the gradient "hero" card; friends' songs sit on plain panels.
|
||||||
|
const hero = !showUser;
|
||||||
return (
|
return (
|
||||||
<View style={[styles.card, pinned && styles.pinned]}>
|
<OnHero.Provider value={hero}>
|
||||||
{/* The card glows with its album art; crossfades when the song changes. */}
|
<View style={[styles.card, hero && styles.heroCard, pinned && styles.pinned]}>
|
||||||
{np.track.artworkUrl && (
|
{hero && <LinearGradient colors={heroGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />}
|
||||||
<Animated.View key={np.track.artworkUrl} style={StyleSheet.absoluteFill} entering={FadeIn.duration(700)} exiting={FadeOut.duration(500)}>
|
|
||||||
<Image source={np.track.artworkUrl} style={styles.backdrop} blurRadius={40} contentFit="cover" />
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
<LinearGradient colors={['rgba(7,7,11,0.05)', 'rgba(7,7,11,0.6)']} style={StyleSheet.absoluteFill} />
|
|
||||||
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
{showUser && (
|
{showUser && (
|
||||||
@@ -74,11 +70,11 @@ export function NowPlayingCard({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
<View style={styles.statusRow}>
|
<View style={styles.statusRow}>
|
||||||
{np.isPlaying && <Equalizer playing />}
|
{np.isPlaying && <Equalizer playing color={hero ? '#ffffff' : colors.live} />}
|
||||||
<Text style={[styles.status, np.isPlaying && styles.live]} numberOfLines={1}>
|
<Text style={[styles.status, hero && styles.heroText, np.isPlaying && (hero ? styles.heroLive : styles.live)]} numberOfLines={1}>
|
||||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||||
<Text
|
<Text
|
||||||
style={[styles.via, np.sourceUrl && styles.viaLink]}
|
style={[styles.via, hero && styles.heroText, np.sourceUrl && styles.viaLink]}
|
||||||
onPress={np.sourceUrl ? () => Linking.openURL(np.sourceUrl!) : undefined}>
|
onPress={np.sourceUrl ? () => Linking.openURL(np.sourceUrl!) : undefined}>
|
||||||
{' · '}
|
{' · '}
|
||||||
{np.sourceApp ?? SERVICE_LABELS[np.source]}
|
{np.sourceApp ?? SERVICE_LABELS[np.source]}
|
||||||
@@ -101,7 +97,7 @@ export function NowPlayingCard({
|
|||||||
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
|
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={toggle} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<ProgressBar np={np} compact={!expanded} />
|
<ProgressBar np={np} compact={!expanded} />
|
||||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} commentCount={np.commentCount ?? 0} />
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
|
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(120)} style={styles.details}>
|
||||||
<ServiceLinks track={np.track} preferred={preferred} />
|
<ServiceLinks track={np.track} preferred={preferred} />
|
||||||
@@ -109,30 +105,33 @@ export function NowPlayingCard({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</OnHero.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
padding: space.md + 2,
|
padding: space.md + 4,
|
||||||
gap: space.sm + 2,
|
gap: space.sm + 2,
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: 'transparent',
|
||||||
|
...panelShadow,
|
||||||
},
|
},
|
||||||
|
heroCard: { overflow: 'hidden', shadowColor: colors.accent, shadowOpacity: 0.4, shadowRadius: 22 },
|
||||||
|
heroText: { color: 'rgba(255,255,255,0.85)' },
|
||||||
|
heroLive: { color: '#ffffff', fontFamily: fonts.bodyMedium },
|
||||||
details: { gap: space.md },
|
details: { gap: space.md },
|
||||||
pinned: { borderColor: 'rgba(251,191,36,0.55)' },
|
pinned: { borderColor: colors.accentLine },
|
||||||
backdrop: { position: 'absolute', top: '-40%', left: '-40%', width: '180%', height: '180%', opacity: 0.55 },
|
|
||||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm },
|
||||||
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
|
user: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexShrink: 1 },
|
||||||
name: { color: colors.text, fontWeight: '700', fontSize: 15, flexShrink: 1 },
|
name: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14.5, flexShrink: 1 },
|
||||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs + 2, flexShrink: 1 },
|
statusRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs + 2, flexShrink: 1 },
|
||||||
status: { color: colors.muted, fontSize: 13, flexShrink: 1 },
|
status: { color: colors.muted, fontSize: 12.5, fontFamily: fonts.body, flexShrink: 1 },
|
||||||
live: { color: colors.live, fontWeight: '600' },
|
live: { color: colors.live, fontFamily: fonts.bodyMedium },
|
||||||
via: { color: colors.muted, fontWeight: '400' },
|
via: { color: colors.muted, fontFamily: fonts.body },
|
||||||
viaLink: { textDecorationLine: 'underline' },
|
viaLink: { textDecorationLine: 'underline' },
|
||||||
pin: { color: colors.muted, fontSize: 21, marginLeft: space.xs },
|
pin: { color: colors.muted, fontSize: 21, marginLeft: space.xs },
|
||||||
pinOn: { color: colors.pin, textShadowColor: 'rgba(251,191,36,0.6)', textShadowRadius: 10 },
|
pinOn: { color: colors.accent, textShadowColor: 'rgba(255,79,139,0.6)', textShadowRadius: 10 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { api } from '@/lib/api';
|
|||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { useRealtime } from '@/lib/useRealtime';
|
import { useRealtime } from '@/lib/useRealtime';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { NowPlayingCard } from './NowPlayingCard';
|
import { NowPlayingCard } from './NowPlayingCard';
|
||||||
import { RecentPlays } from './RecentPlays';
|
import { RecentPlays } from './RecentPlays';
|
||||||
@@ -162,9 +162,9 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
|
||||||
meta: { color: colors.muted, fontSize: 13 },
|
meta: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||||
counts: { flexDirection: 'row', gap: space.lg, marginTop: 2 },
|
counts: { flexDirection: 'row', gap: space.lg, marginTop: 2 },
|
||||||
count: { color: colors.text, fontWeight: '700' },
|
count: { color: colors.text, fontFamily: fonts.display },
|
||||||
quote: {
|
quote: {
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||||
import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared';
|
import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared';
|
||||||
import { colors } from '@/lib/theme';
|
import { brandGradient, fonts, useInk } from '@/lib/theme';
|
||||||
|
|
||||||
/** Where they are in the song. Renders nothing when the source doesn't report a position. */
|
/** Where they are in the song. Renders nothing when the source doesn't report a position. */
|
||||||
export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) {
|
export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) {
|
||||||
@@ -20,6 +20,7 @@ function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const ink = useInk();
|
||||||
const pos = playbackPosition(np, now - receivedAt);
|
const pos = playbackPosition(np, now - receivedAt);
|
||||||
const fraction = pos ? pos.positionMs / pos.durationMs : 0;
|
const fraction = pos ? pos.positionMs / pos.durationMs : 0;
|
||||||
const width = useSharedValue(fraction);
|
const width = useSharedValue(fraction);
|
||||||
@@ -31,15 +32,15 @@ function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
|||||||
if (!pos) return null;
|
if (!pos) return null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrap} accessibilityLabel={`${formatDuration(pos.positionMs)} of ${formatDuration(pos.durationMs)}`}>
|
<View style={styles.wrap} accessibilityLabel={`${formatDuration(pos.positionMs)} of ${formatDuration(pos.durationMs)}`}>
|
||||||
<View style={styles.track}>
|
<View style={[styles.track, { backgroundColor: ink.faint }]}>
|
||||||
<Animated.View style={[styles.fill, fill]}>
|
<Animated.View style={[styles.fill, fill, ink.hero && { backgroundColor: '#ffffff' }]}>
|
||||||
<LinearGradient colors={['#7b2ff7', '#2f8cff']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={StyleSheet.absoluteFill} />
|
{!ink.hero && <LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={StyleSheet.absoluteFill} />}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<View style={styles.times}>
|
<View style={styles.times}>
|
||||||
<Text style={styles.time}>{formatDuration(pos.positionMs)}</Text>
|
<Text style={[styles.time, { color: ink.muted }]}>{formatDuration(pos.positionMs)}</Text>
|
||||||
<Text style={styles.time}>{formatDuration(pos.durationMs)}</Text>
|
<Text style={[styles.time, { color: ink.muted }]}>{formatDuration(pos.durationMs)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -48,8 +49,8 @@ function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: { marginTop: 2 },
|
wrap: { marginTop: 2 },
|
||||||
track: { height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.14)', overflow: 'hidden' },
|
track: { height: 4, borderRadius: 2, overflow: 'hidden' },
|
||||||
fill: { height: '100%', borderRadius: 2, overflow: 'hidden' },
|
fill: { height: '100%', borderRadius: 2, overflow: 'hidden' },
|
||||||
times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
|
||||||
time: { color: colors.muted, fontSize: 12, fontVariant: ['tabular-nums'] },
|
time: { fontSize: 11.5, fontFamily: fonts.body, fontVariant: ['tabular-nums'] },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
|
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
|
||||||
import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
|
import { router } from 'expo-router';
|
||||||
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { tapHaptic } from '@/lib/haptics';
|
import { tapHaptic } from '@/lib/haptics';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, panelShadow, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
||||||
* opens the quick six, and "+" the full picker. One reaction per person.
|
* opens the quick six, and "+" the full picker. One reaction per person.
|
||||||
|
* With `commentCount`, a 💬 chip opens the play's comments.
|
||||||
*/
|
*/
|
||||||
export function Reactions({
|
export function Reactions({
|
||||||
listenId,
|
listenId,
|
||||||
reactions,
|
reactions,
|
||||||
ownerId,
|
ownerId,
|
||||||
|
commentCount,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
listenId: string | null;
|
listenId: string | null;
|
||||||
reactions: ReactionDTO[];
|
reactions: ReactionDTO[];
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
commentCount?: number;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
}) {
|
}) {
|
||||||
const { me } = useSession();
|
const { me } = useSession();
|
||||||
@@ -31,7 +35,8 @@ export function Reactions({
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
||||||
if (!listenId || (!canReact && shown.length === 0)) return null;
|
const showComments = commentCount !== undefined && Boolean(me);
|
||||||
|
if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
|
||||||
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
||||||
|
|
||||||
const choose = async (emoji: string) => {
|
const choose = async (emoji: string) => {
|
||||||
@@ -50,7 +55,7 @@ export function Reactions({
|
|||||||
<View style={[styles.wrap, style]}>
|
<View style={[styles.wrap, style]}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{shown.map((r) => (
|
{shown.map((r) => (
|
||||||
<Animated.View key={r.emoji} entering={ZoomIn.springify()}>
|
<Animated.View key={r.emoji} entering={FadeIn.duration(150)}>
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={!canReact}
|
disabled={!canReact}
|
||||||
onPress={() => choose(r.emoji)}
|
onPress={() => choose(r.emoji)}
|
||||||
@@ -73,6 +78,17 @@ export function Reactions({
|
|||||||
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
|
<Text style={[styles.count, { color: colors.muted }]}>+</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
{showComments && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push(`/play/${listenId}`)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => [styles.chip, pressed && { transform: [{ scale: 0.9 }] }]}>
|
||||||
|
<Text style={styles.emoji}>💬</Text>
|
||||||
|
{commentCount ? <Text style={styles.count}>{commentCount}</Text> : null}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{quickOpen && (
|
{quickOpen && (
|
||||||
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
|
<Animated.View entering={FadeIn.duration(150)} exiting={FadeOut.duration(120)} style={styles.quick}>
|
||||||
@@ -152,13 +168,13 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 9,
|
paddingHorizontal: 9,
|
||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255,255,255,0.16)',
|
borderColor: 'transparent',
|
||||||
},
|
},
|
||||||
mine: { backgroundColor: 'rgba(123,47,247,0.3)', borderColor: 'rgba(123,47,247,0.75)' },
|
mine: { backgroundColor: colors.accentSoft, borderColor: colors.accentLine },
|
||||||
emoji: { fontSize: 16 },
|
emoji: { fontSize: 16 },
|
||||||
count: { color: colors.text, fontSize: 12, fontVariant: ['tabular-nums'] },
|
count: { color: colors.text, fontSize: 12, fontVariant: ['tabular-nums'], fontFamily: fonts.body },
|
||||||
quick: {
|
quick: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
@@ -166,16 +182,15 @@ const styles = StyleSheet.create({
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: '#1a1535',
|
backgroundColor: colors.surface2,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
...panelShadow,
|
||||||
borderColor: 'rgba(255,255,255,0.16)',
|
|
||||||
},
|
},
|
||||||
quickItem: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
|
quickItem: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
|
||||||
quickEmoji: { fontSize: 26 },
|
quickEmoji: { fontSize: 26 },
|
||||||
backdrop: { flex: 1, backgroundColor: 'rgba(5,3,15,0.6)' },
|
backdrop: { flex: 1, backgroundColor: 'rgba(20,18,40,0.6)' },
|
||||||
sheet: {
|
sheet: {
|
||||||
height: '55%',
|
height: '55%',
|
||||||
backgroundColor: '#15112c',
|
backgroundColor: colors.surface,
|
||||||
borderTopLeftRadius: radius.lg,
|
borderTopLeftRadius: radius.lg,
|
||||||
borderTopRightRadius: radius.lg,
|
borderTopRightRadius: radius.lg,
|
||||||
padding: space.md,
|
padding: space.md,
|
||||||
@@ -183,7 +198,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
backgroundColor: 'rgba(255,255,255,0.07)',
|
backgroundColor: colors.surface2,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
paddingHorizontal: space.md,
|
paddingHorizontal: space.md,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Image } from 'expo-image';
|
|||||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
import { openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
import { Card, Heading } from './ui';
|
import { Card, Heading } from './ui';
|
||||||
import { Reactions } from './Reactions';
|
import { Reactions } from './Reactions';
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export function RecentPlays({
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
|
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} style={styles.reactions} />
|
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} commentCount={p.commentCount ?? 0} style={styles.reactions} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{credits.map((s) => (
|
{credits.map((s) => (
|
||||||
@@ -64,9 +64,9 @@ const styles = StyleSheet.create({
|
|||||||
divider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
divider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
||||||
art: { width: 40, height: 40, borderRadius: radius.sm, backgroundColor: colors.surface2 },
|
art: { width: 40, height: 40, borderRadius: radius.sm, backgroundColor: colors.surface2 },
|
||||||
artEmpty: { alignItems: 'center', justifyContent: 'center' },
|
artEmpty: { alignItems: 'center', justifyContent: 'center' },
|
||||||
title: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
title: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14 },
|
||||||
artist: { color: colors.muted, fontSize: 13 },
|
artist: { color: colors.muted, fontSize: 13, fontFamily: fonts.body },
|
||||||
time: { color: colors.muted, fontSize: 12 },
|
time: { color: colors.muted, fontSize: 12 },
|
||||||
reactions: { marginLeft: 40 + space.md, marginTop: -space.sm, marginBottom: space.xs },
|
reactions: { marginLeft: 40 + space.md, marginTop: -space.sm, marginBottom: space.xs },
|
||||||
credit: { color: colors.muted, fontSize: 12, marginTop: space.sm },
|
credit: { color: colors.muted, fontSize: 12, marginTop: space.sm, fontFamily: fonts.body },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SERVICE_LABELS, openUrl, shareText, trackUrl, viewerServices, type Stre
|
|||||||
import { WEB_URL } from '@/lib/config';
|
import { WEB_URL } from '@/lib/config';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
import { colors, fonts, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space, useInk } from '@/lib/theme';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +50,8 @@ function PrimaryButton({ track, preferred, label }: { track: TrackDTO; preferred
|
|||||||
/** Quieter links for your other services, and Share. */
|
/** Quieter links for your other services, and Share. */
|
||||||
export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||||
const { me } = useSession();
|
const { me } = useSession();
|
||||||
|
const ink = useInk();
|
||||||
|
const chip = [styles.service, ink.hero && styles.serviceHero];
|
||||||
const others = viewerServices(me).filter((s) => s !== preferred);
|
const others = viewerServices(me).filter((s) => s !== preferred);
|
||||||
return (
|
return (
|
||||||
<View style={styles.services}>
|
<View style={styles.services}>
|
||||||
@@ -57,17 +59,17 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred:
|
|||||||
const { url, exact } = openUrl(track, s);
|
const { url, exact } = openUrl(track, s);
|
||||||
return (
|
return (
|
||||||
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
<Pressable key={s} onPress={() => open(url)} hitSlop={6} accessibilityRole="link">
|
||||||
<Text style={[styles.service, !exact && { opacity: 0.55 }]}>{SERVICE_LABELS[s]}</Text>
|
<Text style={[chip, !exact && { opacity: 0.55 }]}>{SERVICE_LABELS[s]}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{me && (
|
{me && (
|
||||||
<Pressable onPress={() => router.push(`/send/${track.id}`)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Send to friends">
|
<Pressable onPress={() => router.push(`/send/${track.id}`)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Send to friends">
|
||||||
<Text style={[styles.service, styles.share]}>Send ✉</Text>
|
<Text style={[chip, ink.hero ? styles.shareHero : styles.share]}>Send ✉</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
<Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song">
|
<Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song">
|
||||||
<Text style={[styles.service, styles.share]}>Share ↗</Text>
|
<Text style={[chip, ink.hero ? styles.shareHero : styles.share]}>Share ↗</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -88,6 +90,7 @@ export function TrackRow({
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const ink = useInk();
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Pressable style={styles.rowMain} onPress={onToggle} accessibilityRole="button" accessibilityState={{ expanded }}>
|
<Pressable style={styles.rowMain} onPress={onToggle} accessibilityRole="button" accessibilityState={{ expanded }}>
|
||||||
@@ -98,7 +101,7 @@ export function TrackRow({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.artist} numberOfLines={1}>
|
<Text style={styles.artist} numberOfLines={1}>
|
||||||
{track.artist}
|
{track.artist}
|
||||||
{expanded && track.album ? <Text style={styles.muted}> · {track.album}</Text> : null}
|
{expanded && track.album ? <Text style={{ color: ink.muted }}> · {track.album}</Text> : null}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -109,7 +112,7 @@ export function TrackRow({
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={expanded ? 'Show less' : 'Show more'}
|
accessibilityLabel={expanded ? 'Show less' : 'Show more'}
|
||||||
style={styles.chevron}>
|
style={styles.chevron}>
|
||||||
<Text style={[styles.chevronText, expanded && { transform: [{ rotate: '180deg' }] }]}>⌄</Text>
|
<Text style={[styles.chevronText, { color: ink.muted }, expanded && { transform: [{ rotate: '180deg' }] }]}>⌄</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -143,32 +146,29 @@ const styles = StyleSheet.create({
|
|||||||
track: { flexDirection: 'row', gap: space.md + 2, alignItems: 'flex-start' },
|
track: { flexDirection: 'row', gap: space.md + 2, alignItems: 'flex-start' },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
|
||||||
rowMain: { flex: 1, minWidth: 0, flexDirection: 'row', alignItems: 'center', gap: space.md },
|
rowMain: { flex: 1, minWidth: 0, flexDirection: 'row', alignItems: 'center', gap: space.md },
|
||||||
artwork: {
|
artwork: { backgroundColor: colors.surface2 },
|
||||||
backgroundColor: colors.surface2,
|
artworkEmpty: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#4b3f78' },
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: 'rgba(255,255,255,0.12)',
|
|
||||||
},
|
|
||||||
artworkEmpty: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#3a1a6e' },
|
|
||||||
note: { color: 'rgba(255,255,255,0.85)' },
|
note: { color: 'rgba(255,255,255,0.85)' },
|
||||||
body: { flex: 1, minWidth: 0, gap: 2 },
|
body: { flex: 1, minWidth: 0, gap: 2 },
|
||||||
title: { color: colors.text, fontFamily: fonts.display, fontSize: 16, letterSpacing: -0.2 },
|
title: { color: colors.text, fontFamily: fonts.display, fontSize: 16, letterSpacing: -0.2 },
|
||||||
titleBig: { fontSize: 18 },
|
titleBig: { fontSize: 18 },
|
||||||
artist: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
artist: { color: colors.text, opacity: 0.85, fontSize: 13.5, fontFamily: fonts.body },
|
||||||
muted: { color: colors.muted },
|
muted: { color: colors.muted },
|
||||||
chevron: { paddingHorizontal: space.xs, alignSelf: 'stretch', justifyContent: 'center' },
|
chevron: { paddingHorizontal: space.xs, alignSelf: 'stretch', justifyContent: 'center' },
|
||||||
chevronText: { color: colors.muted, fontSize: 22, lineHeight: 24 },
|
chevronText: { color: colors.muted, fontSize: 22, lineHeight: 24 },
|
||||||
share: { backgroundColor: 'rgba(123,47,247,0.28)', borderColor: 'rgba(123,47,247,0.5)' },
|
share: { backgroundColor: colors.accentSoft, color: '#ffd1e1' },
|
||||||
|
shareHero: { backgroundColor: 'rgba(255,255,255,0.32)' },
|
||||||
|
serviceHero: { backgroundColor: 'rgba(255,255,255,0.2)', color: '#ffffff' },
|
||||||
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
primary: { alignSelf: 'flex-start', marginTop: space.sm },
|
||||||
services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 },
|
services: { flexDirection: 'row', flexWrap: 'wrap', gap: space.xs + 2, marginTop: space.sm + 2 },
|
||||||
service: {
|
service: {
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
paddingHorizontal: space.sm + 2,
|
fontFamily: fonts.bodyMedium,
|
||||||
paddingVertical: 4,
|
paddingHorizontal: space.sm + 4,
|
||||||
|
paddingVertical: 5,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
backgroundColor: colors.surface2,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: 'rgba(255,255,255,0.12)',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ConnectionDTO } from '@musicshare/shared';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
import { SignInError, openSignupPage, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||||
import { successHaptic } from '@/lib/haptics';
|
import { successHaptic } from '@/lib/haptics';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, fonts, radius, space } from '@/lib/theme';
|
||||||
import { Body, Button, Field } from '../ui';
|
import { Body, Button, Field } from '../ui';
|
||||||
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
import { ErrorText, SetupActions, SetupHeader, type SetupProps } from './common';
|
||||||
|
|
||||||
@@ -153,9 +153,9 @@ function Option({ selected, onPress, title, badge, detail }: { selected: boolean
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
option: { borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: space.md, gap: space.xs },
|
option: { borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: space.md, gap: space.xs },
|
||||||
optionSelected: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.16)' },
|
optionSelected: { borderColor: colors.accent, backgroundColor: colors.accentSoft },
|
||||||
optionHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
optionHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
optionTitle: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
optionTitle: { color: colors.text, fontFamily: fonts.display, fontSize: 16 },
|
||||||
badge: { color: colors.accent2, fontSize: 12, fontWeight: '700' },
|
badge: { color: colors.accent, fontSize: 12, fontFamily: fonts.display },
|
||||||
small: { fontSize: 13 },
|
small: { fontSize: 13, fontFamily: fonts.body },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { StyleSheet, View } from 'react-native';
|
import { StyleSheet, View } from 'react-native';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, fonts, space } from '@/lib/theme';
|
||||||
import { Body, Button, Card, Title } from '../ui';
|
import { Body, Button, Card, Title } from '../ui';
|
||||||
|
|
||||||
export interface SetupProps {
|
export interface SetupProps {
|
||||||
@@ -55,5 +55,5 @@ export const ErrorText = ({ children }: { children: string | null }) =>
|
|||||||
children ? <Body style={{ color: colors.danger }}>{children}</Body> : null;
|
children ? <Body style={{ color: colors.danger }}>{children}</Body> : null;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
point: { color: colors.text, fontSize: 14 },
|
point: { color: colors.text, fontSize: 14, fontFamily: fonts.body },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { forwardRef, useState } from 'react';
|
import { forwardRef, useContext, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||||
import { tapHaptic } from '@/lib/haptics';
|
import { tapHaptic } from '@/lib/haptics';
|
||||||
import { brandGradient, colors, fonts, radius, space } from '@/lib/theme';
|
import { OnHero, brandGradient, colors, fonts, panelShadow, radius, space } from '@/lib/theme';
|
||||||
|
|
||||||
export function Title({ style, ...props }: TextProps) {
|
export function Title({ style, ...props }: TextProps) {
|
||||||
return <Text style={[styles.title, style]} {...props} />;
|
return <Text style={[styles.title, style]} {...props} />;
|
||||||
@@ -65,17 +65,19 @@ export function Button({
|
|||||||
...props
|
...props
|
||||||
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
|
}: PressableProps & { title: string; variant?: Variant; loading?: boolean; small?: boolean; style?: ViewProps['style'] }) {
|
||||||
const press = usePressScale();
|
const press = usePressScale();
|
||||||
|
const hero = useContext(OnHero);
|
||||||
const inactive = disabled || loading;
|
const inactive = disabled || loading;
|
||||||
const handlePress = (e: GestureResponderEvent) => {
|
const handlePress = (e: GestureResponderEvent) => {
|
||||||
if (variant === 'primary') tapHaptic();
|
if (variant === 'primary') tapHaptic();
|
||||||
onPress?.(e);
|
onPress?.(e);
|
||||||
};
|
};
|
||||||
|
// On the pink hero card a pink button would vanish: primary goes white with pink text instead.
|
||||||
|
const heroPrimary = hero && variant === 'primary';
|
||||||
|
const textColor = heroPrimary ? colors.accent : variant === 'secondary' ? colors.text : colors.accentText;
|
||||||
const label = loading ? (
|
const label = loading ? (
|
||||||
<ActivityIndicator color={variant === 'secondary' ? colors.text : colors.accentText} />
|
<ActivityIndicator color={textColor} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={[styles.buttonText, small && styles.buttonTextSmall, variant !== 'secondary' && { color: colors.accentText }]}>
|
<Text style={[styles.buttonText, small && styles.buttonTextSmall, { color: textColor }]}>{title}</Text>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[press.style, inactive && { opacity: 0.55 }, style]}>
|
<Animated.View style={[press.style, inactive && { opacity: 0.55 }, style]}>
|
||||||
@@ -86,9 +88,15 @@ export function Button({
|
|||||||
onPressIn={press.onPressIn}
|
onPressIn={press.onPressIn}
|
||||||
onPressOut={press.onPressOut}
|
onPressOut={press.onPressOut}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
style={[styles.button, small && styles.buttonSmall, variant === 'danger' && styles.buttonDanger, variant === 'primary' && styles.buttonPrimary]}
|
style={[
|
||||||
|
styles.button,
|
||||||
|
small && styles.buttonSmall,
|
||||||
|
variant === 'danger' && styles.buttonDanger,
|
||||||
|
variant === 'primary' && styles.buttonPrimary,
|
||||||
|
hero && (variant === 'primary' ? styles.buttonHeroPrimary : styles.buttonHero),
|
||||||
|
]}
|
||||||
{...props}>
|
{...props}>
|
||||||
{variant === 'primary' && (
|
{variant === 'primary' && !hero && (
|
||||||
<LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />
|
<LinearGradient colors={brandGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={StyleSheet.absoluteFill} />
|
||||||
)}
|
)}
|
||||||
{label}
|
{label}
|
||||||
@@ -183,20 +191,18 @@ export function Chip({ label, selected, onPress }: { label: string; selected?: b
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
title: { color: colors.text, fontSize: 30, fontFamily: fonts.display, letterSpacing: -0.6 },
|
title: { color: colors.text, fontSize: 26, fontFamily: fonts.display, letterSpacing: -0.4 },
|
||||||
heading: { color: colors.text, fontSize: 18, fontFamily: fonts.display, marginBottom: space.xs },
|
heading: { color: colors.text, fontSize: 17, fontFamily: fonts.display, marginBottom: space.xs },
|
||||||
body: { color: colors.text, fontSize: 15, lineHeight: 21 },
|
body: { color: colors.text, fontSize: 14.5, lineHeight: 21, fontFamily: fonts.body },
|
||||||
muted: { color: colors.muted },
|
muted: { color: colors.muted },
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
borderColor: colors.border,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderRadius: radius.lg,
|
borderRadius: radius.lg,
|
||||||
padding: space.lg,
|
padding: space.lg + 2,
|
||||||
gap: space.md,
|
gap: space.md,
|
||||||
overflow: 'hidden',
|
...panelShadow,
|
||||||
},
|
},
|
||||||
cardHighlighted: { borderColor: 'rgba(251,191,36,0.55)', borderWidth: 1 },
|
cardHighlighted: { borderColor: colors.accentLine, borderWidth: 1 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
|
row: { flexDirection: 'row', alignItems: 'center', gap: space.sm, flexWrap: 'wrap' },
|
||||||
button: {
|
button: {
|
||||||
minHeight: 46,
|
minHeight: 46,
|
||||||
@@ -205,51 +211,49 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.surface2,
|
backgroundColor: colors.surface2,
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.borderStrong,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
buttonSmall: { minHeight: 36, paddingHorizontal: space.md + 2 },
|
buttonSmall: { minHeight: 36, paddingHorizontal: space.md + 2 },
|
||||||
buttonPrimary: {
|
buttonPrimary: {
|
||||||
borderColor: 'transparent',
|
shadowColor: colors.accent,
|
||||||
shadowColor: '#5b4cf0',
|
shadowOpacity: 0.45,
|
||||||
shadowOpacity: 0.6,
|
|
||||||
shadowRadius: 14,
|
shadowRadius: 14,
|
||||||
shadowOffset: { width: 0, height: 6 },
|
shadowOffset: { width: 0, height: 6 },
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
},
|
},
|
||||||
buttonDanger: { backgroundColor: colors.danger, borderColor: colors.danger },
|
buttonHero: { backgroundColor: 'rgba(255,255,255,0.22)' },
|
||||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 15 },
|
buttonHeroPrimary: { backgroundColor: '#ffffff', shadowColor: '#7a1e4a', shadowOpacity: 0.25 },
|
||||||
|
buttonDanger: { backgroundColor: colors.danger },
|
||||||
|
buttonText: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14.5 },
|
||||||
buttonTextSmall: { fontSize: 13 },
|
buttonTextSmall: { fontSize: 13 },
|
||||||
label: { color: colors.text, fontWeight: '600', fontSize: 14 },
|
label: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 14 },
|
||||||
listRow: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: 13, paddingHorizontal: 2, borderRadius: radius.sm },
|
listRow: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingVertical: 13, paddingHorizontal: 2, borderRadius: radius.sm },
|
||||||
listDivider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
listDivider: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border },
|
||||||
listLabel: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
listLabel: { color: colors.text, fontSize: 14.5, fontFamily: fonts.bodyMedium },
|
||||||
listValue: { flex: 1, textAlign: 'right', color: colors.muted, fontSize: 14 },
|
listValue: { flex: 1, textAlign: 'right', color: colors.muted, fontSize: 13.5, fontFamily: fonts.body },
|
||||||
listChevron: { color: colors.muted, fontSize: 20, lineHeight: 20 },
|
listChevron: { color: colors.muted, fontSize: 20, lineHeight: 20, fontFamily: fonts.body },
|
||||||
reveal: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', paddingHorizontal: space.md },
|
reveal: { position: 'absolute', right: 0, top: 0, bottom: 0, justifyContent: 'center', paddingHorizontal: space.md },
|
||||||
revealText: { color: colors.accent2, fontWeight: '600', fontSize: 14 },
|
revealText: { color: colors.accent, fontFamily: fonts.bodyMedium, fontSize: 13.5 },
|
||||||
input: {
|
input: {
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
paddingHorizontal: space.md + 2,
|
paddingHorizontal: space.md + 2,
|
||||||
paddingVertical: space.sm,
|
paddingVertical: space.sm,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: 'transparent',
|
||||||
backgroundColor: colors.surface2,
|
backgroundColor: colors.surface2,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontSize: 15,
|
fontSize: 14.5,
|
||||||
|
fontFamily: fonts.body,
|
||||||
},
|
},
|
||||||
inputFocused: { borderColor: colors.accent, backgroundColor: 'rgba(124,77,255,0.10)' },
|
inputFocused: { borderColor: colors.accentLine, backgroundColor: '#463f62' },
|
||||||
chip: {
|
chip: {
|
||||||
paddingHorizontal: space.md,
|
paddingHorizontal: space.md + 2,
|
||||||
paddingVertical: space.sm,
|
paddingVertical: space.sm,
|
||||||
borderRadius: radius.pill,
|
borderRadius: radius.pill,
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
backgroundColor: colors.surface2,
|
backgroundColor: colors.surface2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
chipSelected: { borderColor: 'transparent' },
|
chipSelected: {},
|
||||||
chipText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
chipText: { color: colors.text, fontFamily: fonts.bodyMedium, fontSize: 13 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,62 @@
|
|||||||
/** Mirrors the web palette (apps/web/src/app/globals.css). Brand colours come from the logo. */
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the web palette (apps/web/src/app/globals.css): slate-indigo layers with a pink accent.
|
||||||
|
* Panels are a shade lighter than the page instead of outlined.
|
||||||
|
*/
|
||||||
export const colors = {
|
export const colors = {
|
||||||
bg: '#07070b',
|
bg: '#2a2942',
|
||||||
surface: 'rgba(255,255,255,0.05)',
|
surface: '#34334f',
|
||||||
surfaceSolid: '#12121a',
|
surfaceSolid: '#34334f',
|
||||||
surface2: 'rgba(255,255,255,0.09)',
|
surface2: '#403e5e',
|
||||||
border: 'rgba(255,255,255,0.10)',
|
border: 'rgba(255,255,255,0.07)',
|
||||||
borderStrong: 'rgba(255,255,255,0.18)',
|
borderStrong: 'rgba(255,255,255,0.14)',
|
||||||
text: '#f4f3fa',
|
text: '#f4f3fb',
|
||||||
muted: '#9b99ae',
|
muted: '#a19fbd',
|
||||||
accent: '#7c4dff',
|
accent: '#ff4f8b',
|
||||||
accent2: '#2f8cff',
|
accent2: '#8b7dff',
|
||||||
accentText: '#ffffff',
|
accentText: '#ffffff',
|
||||||
live: '#34d399',
|
/** Pink washes for selected chips, "mine" reactions and focus. */
|
||||||
danger: '#fb7185',
|
accentSoft: 'rgba(255,79,139,0.18)',
|
||||||
pin: '#fbbf24',
|
accentLine: 'rgba(255,79,139,0.6)',
|
||||||
|
live: '#3ee0a1',
|
||||||
|
danger: '#ff6b81',
|
||||||
|
pin: '#ffc95c',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** The logo's purple-to-blue, for buttons and highlights. */
|
/** Buttons and selected chips: pink into violet. */
|
||||||
export const brandGradient = ['#7b2ff7', '#5b4cf0', '#2f8cff'] as const;
|
export const brandGradient = ['#ff4f8b', '#d14fc4', '#8b5cf6'] as const;
|
||||||
|
/** The big "your song" card: pink, lavender, sky blue (like a holographic card). */
|
||||||
|
export const heroGradient = ['#ff4f8b', '#e27ad8', '#a99cf6', '#8fc8ff'] as const;
|
||||||
|
|
||||||
export const fonts = {
|
export const fonts = {
|
||||||
display: 'SpaceGrotesk_700Bold',
|
display: 'Poppins_600SemiBold',
|
||||||
displayMedium: 'SpaceGrotesk_500Medium',
|
displayMedium: 'Poppins_500Medium',
|
||||||
|
body: 'Poppins_400Regular',
|
||||||
|
bodyMedium: 'Poppins_500Medium',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
|
export const space = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 } as const;
|
||||||
export const radius = { sm: 8, md: 12, lg: 20, pill: 999 } as const;
|
export const radius = { sm: 10, md: 14, lg: 24, pill: 999 } as const;
|
||||||
|
|
||||||
|
/** Soft drop shadow for panels (the reference separates layers with shadow, not borders). */
|
||||||
|
export const panelShadow = {
|
||||||
|
shadowColor: '#15142a',
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 18,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
elevation: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inside the gradient hero card, text and controls switch to white so they read on pink.
|
||||||
|
* Components that draw muted text or filled buttons ask useInk() which to use.
|
||||||
|
*/
|
||||||
|
export const OnHero = createContext(false);
|
||||||
|
|
||||||
|
export function useInk() {
|
||||||
|
const hero = useContext(OnHero);
|
||||||
|
return hero
|
||||||
|
? { hero, text: '#ffffff', muted: 'rgba(255,255,255,0.82)', faint: 'rgba(255,255,255,0.28)', live: '#ffffff' }
|
||||||
|
: { hero, text: colors.text, muted: colors.muted, faint: 'rgba(255,255,255,0.12)', live: colors.live };
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export default function AdminPage() {
|
|||||||
<strong>{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}</strong>
|
<strong>{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}</strong>
|
||||||
{r.reporter && <span className="muted"> · reported by @{r.reporter.username}</span>}
|
{r.reporter && <span className="muted"> · reported by @{r.reporter.username}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
{r.comment && (
|
||||||
|
<p className="muted small">
|
||||||
|
Their comment{r.comment.id ? '' : ' (since deleted)'}: <q>{r.comment.body}</q>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{r.note && <blockquote>{r.note}</blockquote>}
|
{r.note && <blockquote>{r.note}</blockquote>}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{r.status === 'open' && (
|
{r.status === 'open' && (
|
||||||
|
|||||||
+189
-111
@@ -1,23 +1,31 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #07070b;
|
/* Slate-indigo layers with a pink accent. Panels are a shade apart instead of outlined. */
|
||||||
--surface: rgba(255, 255, 255, 0.045);
|
--bg: #2a2942;
|
||||||
--surface-solid: #12121a;
|
--side: #2a2942;
|
||||||
--surface-2: rgba(255, 255, 255, 0.08);
|
--panel: #34334f;
|
||||||
--border: rgba(255, 255, 255, 0.09);
|
--card: #34334f;
|
||||||
--border-strong: rgba(255, 255, 255, 0.16);
|
--surface: var(--card);
|
||||||
--text: #f4f3fa;
|
--surface-solid: #34334f;
|
||||||
--muted: #9b99ae;
|
--surface-2: #403e5e;
|
||||||
/* Brand: the logo's purple-to-blue. */
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
--accent: #7c4dff;
|
--border-strong: rgba(255, 255, 255, 0.13);
|
||||||
--accent-2: #2f8cff;
|
--text: #f4f3fb;
|
||||||
--grad: linear-gradient(135deg, #7b2ff7 0%, #5b4cf0 50%, #2f8cff 100%);
|
--muted: #a19fbd;
|
||||||
|
--accent: #ff4f8b;
|
||||||
|
--accent-2: #8b7dff;
|
||||||
|
--accent-soft: rgba(255, 79, 139, 0.16);
|
||||||
|
--accent-line: rgba(255, 79, 139, 0.55);
|
||||||
|
--grad: linear-gradient(135deg, #ff4f8b 0%, #d14fc4 55%, #8b5cf6 100%);
|
||||||
|
/* Your own song's card: pink, lavender, sky blue, like a holographic card. */
|
||||||
|
--hero: linear-gradient(135deg, #ff4f8b 0%, #e27ad8 38%, #a99cf6 70%, #8fc8ff 100%);
|
||||||
--accent-text: #fff;
|
--accent-text: #fff;
|
||||||
--live: #34d399;
|
--live: #3ee0a1;
|
||||||
--pin: #fbbf24;
|
--pin: #ffc95c;
|
||||||
--danger: #fb7185;
|
--danger: #ff6b81;
|
||||||
--radius: 20px;
|
--radius: 24px;
|
||||||
--font-display: var(--font-space-grotesk), ui-sans-serif, system-ui, sans-serif;
|
--shadow: 0 18px 40px -22px rgba(14, 12, 32, 0.75);
|
||||||
--font-body: var(--font-inter), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
--font-display: var(--font-poppins), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-body: var(--font-poppins), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@@ -29,67 +37,57 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 15px/1.55 var(--font-body);
|
font: 14.5px/1.6 var(--font-body);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slowly drifting colour behind everything, so the app never feels static. */
|
|
||||||
.ambient { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; }
|
|
||||||
.ambient::before, .ambient::after {
|
|
||||||
content: ''; position: absolute; width: 60vmax; height: 60vmax; border-radius: 50%;
|
|
||||||
filter: blur(90px); opacity: 0.34;
|
|
||||||
}
|
|
||||||
.ambient::before { background: #6d28d9; top: -25vmax; left: -15vmax; animation: drift-a 28s ease-in-out infinite alternate; }
|
|
||||||
.ambient::after { background: #1d6fe0; bottom: -30vmax; right: -20vmax; animation: drift-b 34s ease-in-out infinite alternate; }
|
|
||||||
@keyframes drift-a { to { transform: translate(18vmax, 12vmax) scale(1.15); } }
|
|
||||||
@keyframes drift-b { to { transform: translate(-16vmax, -10vmax) scale(0.9); } }
|
|
||||||
|
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
h1, h2, .brand, .display { font-family: var(--font-display); letter-spacing: -0.02em; }
|
h1, h2, .brand, .display { font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em; }
|
||||||
h1 { font-size: 1.85rem; margin: 0; line-height: 1.15; }
|
h1 { font-size: 1.85rem; margin: 0; line-height: 1.15; }
|
||||||
h2 { font-size: 1.1rem; margin: 0 0 0.5rem; }
|
h2 { font-size: 1.1rem; margin: 0 0 0.5rem; }
|
||||||
input, textarea, select {
|
input, textarea, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.7rem 0.85rem;
|
padding: 0.7rem 0.85rem;
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid transparent;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
input:focus, textarea:focus, select:focus {
|
input:focus, textarea:focus, select:focus {
|
||||||
outline: none; border-color: var(--accent); background: rgba(124, 77, 255, 0.08);
|
outline: none; border-color: var(--accent-line); background: #463f62;
|
||||||
box-shadow: 0 0 0 4px rgba(124, 77, 255, 0.2);
|
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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-name { display: flex; align-items: center; gap: 0.6rem; font-family: var(--font-display); font-size: 1.05rem; font-weight: 600; 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; }
|
.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; }
|
||||||
.chip-small { display: inline-block; margin-left: 0.5rem; padding: 0.05rem 0.5rem; border-radius: 999px; background: rgba(255, 255, 255, 0.1); font-size: 0.75rem; color: var(--text); }
|
.chip-small { display: inline-block; margin-left: 0.5rem; padding: 0.05rem 0.5rem; border-radius: 999px; background: rgba(255, 255, 255, 0.1); font-size: 0.75rem; color: var(--text); }
|
||||||
.stats a { color: inherit; text-decoration: none; }
|
.stats a { color: inherit; text-decoration: none; }
|
||||||
.stats a:hover strong { text-decoration: underline; }
|
.stats a:hover strong { text-decoration: underline; }
|
||||||
.menu { position: relative; }
|
.menu { position: relative; }
|
||||||
.menu-list { position: absolute; right: 0; top: calc(100% + 0.35rem); z-index: 20; min-width: 12rem; display: flex; flex-direction: column; padding: 0.35rem; border-radius: 0.8rem; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 18px 40px -12px rgba(0, 0, 0, 0.6); }
|
.menu-list { position: absolute; right: 0; top: calc(100% + 0.35rem); z-index: 20; min-width: 12rem; display: flex; flex-direction: column; padding: 0.35rem; border-radius: 16px; background: var(--surface-2); box-shadow: var(--shadow); }
|
||||||
.menu-list button { all: unset; cursor: pointer; padding: 0.55rem 0.75rem; border-radius: 0.55rem; font-size: 0.92rem; }
|
.menu-list button { all: unset; cursor: pointer; padding: 0.55rem 0.75rem; border-radius: 0.55rem; font-size: 0.92rem; }
|
||||||
.menu-list button:hover, .menu-list button:focus-visible { background: rgba(255, 255, 255, 0.08); }
|
.menu-list button:hover, .menu-list button:focus-visible { background: rgba(255, 255, 255, 0.08); }
|
||||||
.menu-list .danger { color: var(--danger); }
|
.menu-list .danger { color: var(--danger); }
|
||||||
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: #15112c; }
|
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: var(--panel); border: 0; }
|
||||||
.modal::backdrop { background: rgba(5, 3, 15, 0.7); backdrop-filter: blur(4px); }
|
.modal::backdrop { background: rgba(20, 18, 40, 0.65); backdrop-filter: blur(4px); }
|
||||||
.modal p { margin-bottom: 1rem; }
|
.modal p { margin-bottom: 1rem; }
|
||||||
.modal .form p { margin: 0; }
|
.modal .form p { margin: 0; }
|
||||||
.pick-list { max-height: 45vh; overflow-y: auto; }
|
.pick-list { max-height: 45vh; overflow-y: auto; }
|
||||||
.pick-list .checkbox { display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-weight: 500; }
|
.pick-list .checkbox { display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-weight: 500; }
|
||||||
.bell {
|
.bell {
|
||||||
position: relative; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 999px; margin-left: auto; margin-right: 1rem;
|
position: relative; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 999px; margin-left: auto; margin-right: 1rem;
|
||||||
color: var(--muted); background: rgba(255, 255, 255, 0.06); transition: color 0.2s, background 0.2s;
|
color: var(--text); background: var(--surface-2); transition: color 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
.bell:hover { color: var(--text); background: rgba(255, 255, 255, 0.12); }
|
.bell:hover { color: var(--accent); background: #4a4868; }
|
||||||
.bell-badge {
|
.bell-badge {
|
||||||
position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px;
|
position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px;
|
||||||
background: var(--danger); color: #fff; font-size: 0.68rem; font-weight: 700; display: grid; place-items: center;
|
background: var(--accent); color: #fff; font-size: 0.68rem; font-weight: 600; display: grid; place-items: center;
|
||||||
}
|
}
|
||||||
.notifications { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
.notifications { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
.notification { display: flex; align-items: center; gap: 0.8rem; padding: 0.7rem 0.8rem; border-radius: 14px; text-decoration: none; color: var(--text); }
|
.notification { display: flex; align-items: center; gap: 0.8rem; padding: 0.7rem 0.8rem; border-radius: 14px; text-decoration: none; color: var(--text); }
|
||||||
@@ -103,12 +101,12 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.people .user-chip { min-width: 0; }
|
.people .user-chip { min-width: 0; }
|
||||||
.admin-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.75rem; }
|
.admin-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.75rem; }
|
||||||
.admin-stat { text-align: center; padding: 1rem; }
|
.admin-stat { text-align: center; padding: 1rem; }
|
||||||
.admin-stat-value { font-family: var(--font-display); font-size: 1.8rem; font-weight: 700; }
|
.admin-stat-value { font-family: var(--font-display); font-size: 1.8rem; font-weight: 600; }
|
||||||
.reports { list-style: none; padding: 0; margin: 0.75rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
.reports { list-style: none; padding: 0; margin: 0.75rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
.report { padding: 0.85rem; border-radius: 0.8rem; background: rgba(255, 255, 255, 0.04); display: flex; flex-direction: column; gap: 0.5rem; }
|
.report { padding: 0.85rem; border-radius: 16px; background: var(--surface-2); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.report p { margin: 0; }
|
.report p { margin: 0; }
|
||||||
.chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); }
|
.chip-small.danger { background: rgba(251, 113, 133, 0.18); color: var(--danger); }
|
||||||
.banner { max-width: 680px; margin: 0 auto 0.5rem; padding: 0.6rem 1rem; border-radius: 0.8rem; background: rgba(123, 47, 247, 0.16); border: 1px solid rgba(123, 47, 247, 0.35); font-size: 0.9rem; }
|
.banner { max-width: 680px; margin: 0 auto 0.5rem; padding: 0.6rem 1rem; border-radius: 0.8rem; background: var(--accent-soft); border: 1px solid var(--accent-line); font-size: 0.9rem; }
|
||||||
.banner .link-button { font-size: inherit; color: var(--text); text-decoration: underline; }
|
.banner .link-button { font-size: inherit; color: var(--text); text-decoration: underline; }
|
||||||
.footer { max-width: 680px; margin: 0 auto; padding: 0 1rem 2.5rem; display: flex; gap: 1.25rem; justify-content: center; font-size: 0.85rem; }
|
.footer { max-width: 680px; margin: 0 auto; padding: 0 1rem 2.5rem; display: flex; gap: 1.25rem; justify-content: center; font-size: 0.85rem; }
|
||||||
.footer a { color: var(--muted); text-decoration: none; }
|
.footer a { color: var(--muted); text-decoration: none; }
|
||||||
@@ -122,20 +120,38 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); }
|
.song-art { width: 100%; max-width: 360px; aspect-ratio: 1; object-fit: cover; border-radius: 1.1rem; align-self: center; box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.8); }
|
||||||
.reactions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.7rem; position: relative; }
|
.reactions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-top: 0.7rem; position: relative; }
|
||||||
.recent .reactions { margin: 0.25rem 0 0.4rem 3.4rem; }
|
.recent .reactions { margin: 0.25rem 0 0.4rem 3.4rem; }
|
||||||
.reaction { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.07); color: var(--text); font: inherit; font-size: 0.9rem; cursor: pointer; transition: transform 0.15s, background 0.2s; }
|
.reaction { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.6rem; border-radius: 999px; border: 1px solid transparent; background: rgba(255, 255, 255, 0.1); color: var(--text); font: inherit; font-size: 0.9rem; cursor: pointer; transition: transform 0.15s, background 0.2s; }
|
||||||
.reaction:hover:not(:disabled) { transform: translateY(-1px); background: rgba(255, 255, 255, 0.13); }
|
.reaction:hover:not(:disabled) { transform: translateY(-1px); background: rgba(255, 255, 255, 0.16); }
|
||||||
.reaction:active:not(:disabled) { transform: scale(0.9); }
|
.reaction:active:not(:disabled) { transform: scale(0.9); }
|
||||||
.reaction:disabled { cursor: default; }
|
.reaction:disabled { cursor: default; }
|
||||||
.reaction.mine { border-color: rgba(123, 47, 247, 0.7); background: rgba(123, 47, 247, 0.28); }
|
.reaction.mine { border-color: var(--accent-line); background: var(--accent-soft); }
|
||||||
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
|
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
|
||||||
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
||||||
.reaction-add { color: var(--muted); font-size: 1rem; }
|
.reaction-add { color: var(--muted); font-size: 1rem; }
|
||||||
|
a.reaction { text-decoration: none; }
|
||||||
|
|
||||||
|
/* Comments on a play (/play/[id]) */
|
||||||
|
.played-by { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
.played-by-user { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--text); }
|
||||||
|
.comments { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
.comments h2 { margin: 0; }
|
||||||
|
.comment { display: flex; gap: 0.75rem; align-items: flex-start; }
|
||||||
|
.comment-main { flex: 1; min-width: 0; }
|
||||||
|
.comment-main a { color: var(--text); }
|
||||||
|
.comment-body { margin: 0.15rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.45; }
|
||||||
|
.comment-actions { display: flex; gap: 0.6rem; opacity: 0.6; }
|
||||||
|
.comment:hover .comment-actions, .comment:focus-within .comment-actions { opacity: 1; }
|
||||||
|
.link-button { background: none; border: 0; padding: 0; font: inherit; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
|
||||||
|
.link-button:hover { color: var(--text); text-decoration: underline; }
|
||||||
|
.comment-form { display: flex; gap: 0.6rem; align-items: flex-end; }
|
||||||
|
.comment-form textarea { flex: 1; resize: vertical; min-height: 2.6rem; }
|
||||||
|
@media (max-width: 520px) { .comment-actions { opacity: 1; } }
|
||||||
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
|
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
|
||||||
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
|
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
|
||||||
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
|
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: var(--surface-2); box-shadow: var(--shadow); animation: pop 0.18s ease-out; white-space: nowrap; }
|
||||||
.reaction-quick button { all: unset; cursor: pointer; font-size: 1.45rem; padding: 0.2rem 0.3rem; border-radius: 999px; transition: transform 0.15s; }
|
.reaction-quick button { all: unset; cursor: pointer; font-size: 1.45rem; padding: 0.2rem 0.3rem; border-radius: 999px; transition: transform 0.15s; }
|
||||||
.reaction-quick button:hover { transform: scale(1.25); }
|
.reaction-quick button:hover { transform: scale(1.25); }
|
||||||
.reaction-quick button.mine { background: rgba(123, 47, 247, 0.35); }
|
.reaction-quick button.mine { background: var(--accent-soft); }
|
||||||
.reaction-quick .reaction-more { font-size: 0.85rem; color: var(--muted); padding: 0.3rem 0.6rem; }
|
.reaction-quick .reaction-more { font-size: 0.85rem; color: var(--muted); padding: 0.3rem 0.6rem; }
|
||||||
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.92); } to { opacity: 1; transform: none; } }
|
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.92); } to { opacity: 1; transform: none; } }
|
||||||
.emoji-search { width: 100%; margin-bottom: 0.6rem; }
|
.emoji-search { width: 100%; margin-bottom: 0.6rem; }
|
||||||
@@ -149,8 +165,8 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.list { display: flex; flex-direction: column; margin: 0.25rem -0.4rem 0; }
|
.list { display: flex; flex-direction: column; margin: 0.25rem -0.4rem 0; }
|
||||||
.list-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 0.75rem; padding: 0.8rem 0.4rem; border-top: 1px solid var(--border); cursor: pointer; border-radius: 0.5rem; transition: background 0.15s; }
|
.list-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 0.75rem; padding: 0.8rem 0.4rem; border-top: 1px solid var(--border); cursor: pointer; border-radius: 0.5rem; transition: background 0.15s; }
|
||||||
.list-row:first-child { border-top: 0; }
|
.list-row:first-child { border-top: 0; }
|
||||||
.list-row:hover, .list-row:focus-visible { background: rgba(255, 255, 255, 0.05); }
|
.list-row:hover, .list-row:focus-visible { background: rgba(255, 255, 255, 0.04); }
|
||||||
.list-label { font-weight: 600; flex-shrink: 0; }
|
.list-label { font-weight: 500; flex-shrink: 0; }
|
||||||
.list-value { flex: 1; min-width: 0; text-align: right; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.list-value { flex: 1; min-width: 0; text-align: right; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.list-chevron { color: var(--muted); font-size: 1.2rem; line-height: 1; transition: transform 0.2s; margin-left: auto; }
|
.list-chevron { color: var(--muted); font-size: 1.2rem; line-height: 1; transition: transform 0.2s; margin-left: auto; }
|
||||||
.list-value + .list-chevron { margin-left: 0; }
|
.list-value + .list-chevron { margin-left: 0; }
|
||||||
@@ -168,25 +184,22 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.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; }
|
||||||
.notice {
|
.notice { background: var(--card); border-radius: 18px; padding: 0.9rem 1.1rem; margin: 0; box-shadow: var(--shadow); }
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 0.85rem 1rem; margin: 0;
|
.section-label { margin: 0; font-size: 0.85rem; color: var(--muted); font-weight: 500; }
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
.section-label { margin: 0; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-weight: 700; }
|
|
||||||
.narrow { max-width: 420px; margin: 2.5rem auto; }
|
.narrow { max-width: 420px; margin: 2.5rem auto; }
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
padding: 0.9rem 1.25rem; border-bottom: 1px solid var(--border);
|
padding: 0.9rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||||
position: sticky; top: 0; background: rgba(7, 7, 11, 0.65); backdrop-filter: blur(18px) saturate(1.4); z-index: 10;
|
position: sticky; top: 0; background: rgba(42, 41, 66, 0.85); backdrop-filter: blur(18px) saturate(1.4); z-index: 10;
|
||||||
}
|
}
|
||||||
.brand { font-weight: 700; text-decoration: none; font-size: 1.2rem; display: inline-flex; align-items: center; gap: 0.5rem; }
|
.brand { font-weight: 700; text-decoration: none; font-size: 1.2rem; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||||
.brand-logo { width: 30px; height: 30px; border-radius: 8px; box-shadow: 0 4px 14px -4px rgba(47, 140, 255, 0.6); }
|
.brand-logo { width: 30px; height: 30px; border-radius: 8px; box-shadow: 0 4px 14px -4px rgba(255, 79, 139, 0.6); }
|
||||||
.nav-links { display: flex; gap: 1.1rem; align-items: center; }
|
.nav-links { display: flex; gap: 1.1rem; align-items: center; }
|
||||||
.nav-links a { text-decoration: none; color: var(--muted); transition: color 0.2s; }
|
.nav-links a { text-decoration: none; color: var(--muted); transition: color 0.2s; }
|
||||||
.nav-links a:hover { color: var(--text); }
|
.nav-links a:hover { color: var(--text); }
|
||||||
.nav-links a.btn-primary { color: var(--accent-text); }
|
.nav-links a.btn-primary { color: var(--accent-text); }
|
||||||
.nav-links a.active { color: var(--text); font-weight: 600; }
|
.nav-links a.active { color: var(--accent); font-weight: 500; }
|
||||||
/* Phones: main places live in a bottom tab bar, like a native app. */
|
/* Phones: main places live in a bottom tab bar, like a native app. */
|
||||||
.tabbar { display: none; }
|
.tabbar { display: none; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -197,41 +210,40 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.tabbar {
|
.tabbar {
|
||||||
display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||||
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
|
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
|
||||||
background: rgba(7, 7, 11, 0.82); backdrop-filter: blur(18px) saturate(1.4); border-top: 1px solid var(--border);
|
background: rgba(42, 41, 66, 0.92); backdrop-filter: blur(18px) saturate(1.4); border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.tabbar a {
|
.tabbar a {
|
||||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding: 0.35rem 0;
|
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding: 0.35rem 0;
|
||||||
color: var(--muted); text-decoration: none; font-size: 0.7rem; font-weight: 600; transition: color 0.2s;
|
color: var(--muted); text-decoration: none; font-size: 0.7rem; font-weight: 600; transition: color 0.2s;
|
||||||
}
|
}
|
||||||
.tabbar a.active { color: var(--text); }
|
.tabbar a.active { color: var(--accent); }
|
||||||
.tabbar a.active svg { color: #8b9dff; }
|
|
||||||
body:has(.tabbar) .container { padding-bottom: 6.5rem; }
|
body:has(.tabbar) .container { padding-bottom: 6.5rem; }
|
||||||
body:has(.tabbar) .footer { padding-bottom: 6rem; }
|
body:has(.tabbar) .footer { padding-bottom: 6rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
|
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
|
||||||
padding: 0.55rem 1.05rem; border-radius: 999px; border: 1px solid var(--border-strong);
|
padding: 0.55rem 1.1rem; border-radius: 999px; border: 1px solid transparent;
|
||||||
background: var(--surface-2); color: var(--text); font: inherit; font-weight: 600;
|
background: var(--surface-2); color: var(--text); font: inherit; font-weight: 500;
|
||||||
text-decoration: none; cursor: pointer; white-space: nowrap;
|
text-decoration: none; cursor: pointer; white-space: nowrap;
|
||||||
transition: transform 0.25s var(--spring), border-color 0.2s, background 0.2s, box-shadow 0.25s, filter 0.2s;
|
transition: transform 0.25s var(--spring), border-color 0.2s, background 0.2s, box-shadow 0.25s, filter 0.2s;
|
||||||
}
|
}
|
||||||
.btn:hover { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.12); transform: translateY(-1px); }
|
.btn:hover { background: #4a4868; transform: translateY(-1px); }
|
||||||
.btn:active { transform: scale(0.96); }
|
.btn:active { transform: scale(0.96); }
|
||||||
.btn:disabled { opacity: 0.55; cursor: default; transform: none; }
|
.btn:disabled { opacity: 0.55; cursor: default; transform: none; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--grad); border-color: transparent; color: var(--accent-text);
|
background: var(--grad); border-color: transparent; color: var(--accent-text);
|
||||||
box-shadow: 0 6px 22px -6px rgba(91, 76, 240, 0.6);
|
box-shadow: 0 8px 22px -8px rgba(255, 79, 139, 0.7);
|
||||||
}
|
}
|
||||||
.btn-primary:hover { background: var(--grad); filter: brightness(1.08); box-shadow: 0 10px 28px -6px rgba(47, 140, 255, 0.65); }
|
.btn-primary:hover { background: var(--grad); filter: brightness(1.08); box-shadow: 0 12px 28px -8px rgba(255, 79, 139, 0.75); }
|
||||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
.btn-small { padding: 0.3rem 0.75rem; font-size: 0.82rem; }
|
.btn-small { padding: 0.3rem 0.75rem; font-size: 0.82rem; }
|
||||||
.link-button { background: none; border: 0; color: var(--muted); font: inherit; cursor: pointer; padding: 0; transition: color 0.2s; }
|
.link-button { background: none; border: 0; color: var(--muted); font: inherit; cursor: pointer; padding: 0; transition: color 0.2s; }
|
||||||
.link-button:hover { color: var(--text); }
|
.link-button:hover { color: var(--text); }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
position: relative; background: var(--card); border-radius: var(--radius);
|
||||||
padding: 1.1rem; backdrop-filter: blur(20px) saturate(1.3); overflow: hidden;
|
padding: 1.25rem; box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.card-header { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; }
|
.card-header { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; }
|
||||||
.card-header .user-chip { flex-shrink: 0; }
|
.card-header .user-chip { flex-shrink: 0; }
|
||||||
@@ -245,45 +257,46 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.services-picker legend { font-weight: 600; font-size: 0.9rem; padding: 0; margin-bottom: 0.35rem; }
|
.services-picker legend { font-weight: 600; font-size: 0.9rem; padding: 0; margin-bottom: 0.35rem; }
|
||||||
.form label.checkbox { flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
|
.form label.checkbox { flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
|
||||||
|
|
||||||
/* Now-playing cards take their colour from the album art. */
|
.np-card { padding: 1rem 1.15rem; border: 1px solid transparent; }
|
||||||
.np-card { border-color: rgba(255, 255, 255, 0.11); padding: 0.9rem 1rem; }
|
.np-card .card-header { margin-bottom: 0.7rem; }
|
||||||
.np-card .card-header { margin-bottom: 0.65rem; }
|
.np-card .reactions { margin-top: 0.6rem; }
|
||||||
.np-card .reactions { margin-top: 0.55rem; }
|
.np-card.pinned { border-color: var(--accent-line); }
|
||||||
.np-card.pinned { border-color: rgba(251, 191, 36, 0.45); box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.15), 0 18px 40px -20px rgba(251, 191, 36, 0.35); }
|
|
||||||
.np-backdrop {
|
/* Your own song: the gradient "hero" card, with everything on it in white. */
|
||||||
position: absolute; inset: -40%; z-index: 0; background-size: cover; background-position: center;
|
.np-hero { background: var(--hero); overflow: hidden; box-shadow: 0 24px 50px -24px rgba(255, 79, 139, 0.75); color: #fff; }
|
||||||
filter: blur(40px) saturate(2.2) brightness(1.1); opacity: 0.6; transform: scale(1.1); transition: opacity 0.6s;
|
.np-hero .muted, .np-hero .status, .np-hero .status-live, .np-hero .progress-times, .np-hero .lyrics-note, .np-hero .lyrics-source { color: rgba(255, 255, 255, 0.85); }
|
||||||
}
|
.np-hero .status-live { font-weight: 600; color: #fff; }
|
||||||
.np-card.playing .np-backdrop { animation: breathe 9s ease-in-out infinite alternate; }
|
.np-hero .progress-track { background: rgba(255, 255, 255, 0.3); }
|
||||||
@keyframes breathe { to { transform: scale(1.3) rotate(8deg); opacity: 0.75; } }
|
.np-hero .progress-fill { background: #fff; }
|
||||||
.np-card::after {
|
.np-hero .btn-primary { background: #fff; color: var(--accent); box-shadow: 0 8px 20px -10px rgba(122, 30, 74, 0.5); }
|
||||||
content: ''; position: absolute; inset: 0; z-index: 0;
|
.np-hero .btn-primary:hover { background: #fff; filter: none; }
|
||||||
background: linear-gradient(180deg, rgba(7, 7, 11, 0.05), rgba(7, 7, 11, 0.45));
|
.np-hero .btn, .np-hero .service-link, .np-hero .chevron, .np-hero .reaction { background: rgba(255, 255, 255, 0.24); color: #fff; }
|
||||||
}
|
.np-hero .chevron:hover, .np-hero .service-link:hover, .np-hero .reaction:hover:not(:disabled) { background: rgba(255, 255, 255, 0.36); color: #fff; }
|
||||||
.np-card > *:not(.np-backdrop) { position: relative; z-index: 1; }
|
.np-hero .track-artist, .np-hero a { color: #fff; }
|
||||||
|
.np-hero .artwork { box-shadow: 0 12px 26px -12px rgba(80, 20, 70, 0.6); }
|
||||||
|
|
||||||
.pin {
|
.pin {
|
||||||
background: none; border: 0; cursor: pointer; font-size: 1.25rem; line-height: 1; color: var(--muted);
|
background: none; border: 0; cursor: pointer; font-size: 1.25rem; line-height: 1; color: var(--muted);
|
||||||
padding: 0.15rem 0.25rem; transition: color 0.2s;
|
padding: 0.15rem 0.25rem; transition: color 0.2s;
|
||||||
}
|
}
|
||||||
.pin:hover, .pin.pinned { color: var(--pin); }
|
.pin:hover, .pin.pinned { color: var(--accent); }
|
||||||
.pin.pinned { text-shadow: 0 0 14px rgba(251, 191, 36, 0.6); }
|
.pin.pinned { text-shadow: 0 0 14px rgba(255, 79, 139, 0.6); }
|
||||||
|
|
||||||
.user-chip { display: inline-flex; align-items: center; gap: 0.55rem; text-decoration: none; font-weight: 600; }
|
.user-chip { display: inline-flex; align-items: center; gap: 0.55rem; text-decoration: none; font-weight: 500; }
|
||||||
.user-chip:hover .user-name { text-decoration: underline; }
|
.user-chip:hover .user-name { text-decoration: underline; }
|
||||||
.avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; display: block; }
|
.avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; display: block; }
|
||||||
.avatar-empty { display: grid; place-items: center; background: linear-gradient(135deg, #4a1278, #1872cf); color: #fff; font-weight: 700; font-family: var(--font-display); }
|
.avatar-empty { display: grid; place-items: center; background: linear-gradient(135deg, #ff4f8b, #8b5cf6); color: #fff; font-weight: 600; font-family: var(--font-display); }
|
||||||
/* A spinning gradient ring while someone is listening. */
|
/* A spinning gradient ring while someone is listening. */
|
||||||
.avatar-ring { position: relative; display: inline-grid; place-items: center; border-radius: 50%; padding: 2px; flex-shrink: 0; }
|
.avatar-ring { position: relative; display: inline-grid; place-items: center; border-radius: 50%; padding: 2px; flex-shrink: 0; }
|
||||||
.avatar-ring.live::before {
|
.avatar-ring.live::before {
|
||||||
content: ''; position: absolute; inset: 0; border-radius: 50%;
|
content: ''; position: absolute; inset: 0; border-radius: 50%;
|
||||||
background: conic-gradient(from 0deg, #7b2ff7, #2f8cff, #34d399, #7b2ff7); animation: spin 3.5s linear infinite;
|
background: conic-gradient(from 0deg, #ff4f8b, #8b7dff, #3ee0a1, #ff4f8b); animation: spin 3.5s linear infinite;
|
||||||
}
|
}
|
||||||
.avatar-ring > * { position: relative; border: 2px solid var(--bg); border-radius: 50%; }
|
.avatar-ring > * { position: relative; border: 2px solid var(--card); border-radius: 50%; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
.status { font-size: 0.85rem; color: var(--muted); display: inline-flex; align-items: center; gap: 0.45rem; }
|
.status { font-size: 0.85rem; color: var(--muted); display: inline-flex; align-items: center; gap: 0.45rem; }
|
||||||
.status-live { color: var(--live); font-weight: 600; }
|
.status-live { color: var(--live); font-weight: 500; }
|
||||||
|
|
||||||
/* Equalizer bars: dancing while playing, resting when paused. */
|
/* Equalizer bars: dancing while playing, resting when paused. */
|
||||||
.eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 14px; }
|
.eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 14px; }
|
||||||
@@ -296,15 +309,15 @@ input:focus, textarea:focus, select:focus {
|
|||||||
|
|
||||||
.track { display: flex; gap: 1rem; align-items: flex-start; }
|
.track { display: flex; gap: 1rem; align-items: flex-start; }
|
||||||
.artwork {
|
.artwork {
|
||||||
width: 112px; height: 112px; border-radius: 14px; object-fit: cover; flex-shrink: 0; background: var(--surface-2);
|
width: 112px; height: 112px; border-radius: 16px; object-fit: cover; flex-shrink: 0; background: var(--surface-2);
|
||||||
box-shadow: 0 14px 30px -12px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
box-shadow: 0 14px 30px -14px rgba(10, 8, 30, 0.8);
|
||||||
transition: transform 0.4s var(--spring);
|
transition: transform 0.4s var(--spring);
|
||||||
}
|
}
|
||||||
.track:hover .artwork { transform: scale(1.04) rotate(-1deg); }
|
.track:hover .artwork { transform: scale(1.04) rotate(-1deg); }
|
||||||
.artwork-empty { display: grid; place-items: center; font-size: 2rem; color: rgba(255, 255, 255, 0.85); background: linear-gradient(135deg, #4a1278, #1872cf); }
|
.artwork-empty { display: grid; place-items: center; font-size: 2rem; color: rgba(255, 255, 255, 0.85); background: linear-gradient(135deg, #4b3f78, #6a4c9c); }
|
||||||
.artwork-sm { width: 44px; height: 44px; border-radius: 9px; object-fit: cover; flex-shrink: 0; box-shadow: 0 6px 14px -8px rgba(0, 0, 0, 0.8); }
|
.artwork-sm { width: 44px; height: 44px; border-radius: 9px; object-fit: cover; flex-shrink: 0; box-shadow: 0 6px 14px -8px rgba(0, 0, 0, 0.8); }
|
||||||
.track-body { min-width: 0; flex: 1; }
|
.track-body { min-width: 0; flex: 1; }
|
||||||
.track-title { font-family: var(--font-display); font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.track-title { font-family: var(--font-display); font-weight: 600; font-size: 1.15rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.track-artist { color: var(--text); opacity: 0.82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.track-artist { color: var(--text); opacity: 0.82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.progress { margin-top: 0.85rem; }
|
.progress { margin-top: 0.85rem; }
|
||||||
.track-row { display: flex; align-items: center; gap: 0.6rem; }
|
.track-row { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
@@ -318,9 +331,9 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.track-row:hover .artwork { transform: scale(1.04) rotate(-1deg); }
|
.track-row:hover .artwork { transform: scale(1.04) rotate(-1deg); }
|
||||||
.chevron {
|
.chevron {
|
||||||
display: grid; place-items: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 999px; border: 0; cursor: pointer;
|
display: grid; place-items: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 999px; border: 0; cursor: pointer;
|
||||||
background: rgba(255, 255, 255, 0.06); color: var(--muted); transition: transform 0.3s var(--spring), color 0.2s, background 0.2s;
|
background: var(--surface-2); color: var(--muted); transition: transform 0.3s var(--spring), color 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
.chevron:hover { color: var(--text); background: rgba(255, 255, 255, 0.12); }
|
.chevron:hover { color: var(--text); background: #4a4868; }
|
||||||
.chevron.open { transform: rotate(180deg); }
|
.chevron.open { transform: rotate(180deg); }
|
||||||
.np-card .progress { margin-top: 0.65rem; }
|
.np-card .progress { margin-top: 0.65rem; }
|
||||||
.np-details { overflow: hidden; }
|
.np-details { overflow: hidden; }
|
||||||
@@ -330,37 +343,37 @@ input:focus, textarea:focus, select:focus {
|
|||||||
.lyric { margin: 0; line-height: 1.45; transition: opacity 0.3s, font-size 0.3s; }
|
.lyric { margin: 0; line-height: 1.45; transition: opacity 0.3s, font-size 0.3s; }
|
||||||
.lyric-past { opacity: 0.4; }
|
.lyric-past { opacity: 0.4; }
|
||||||
.lyric-next { opacity: 0.65; }
|
.lyric-next { opacity: 0.65; }
|
||||||
.lyric-now { font-family: var(--font-display); font-weight: 700; font-size: 1.08rem; }
|
.lyric-now { font-family: var(--font-display); font-weight: 600; font-size: 1.08rem; }
|
||||||
.lyrics-note { margin: 0.9rem 0 0; font-size: 0.85rem; }
|
.lyrics-note { margin: 0.9rem 0 0; font-size: 0.85rem; }
|
||||||
.lyrics-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 0.5rem; font-size: 0.82rem; }
|
.lyrics-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 0.5rem; font-size: 0.82rem; }
|
||||||
.lyrics-source { margin-left: auto; font-size: 0.72rem; }
|
.lyrics-source { margin-left: auto; font-size: 0.72rem; }
|
||||||
.progress-track { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); overflow: hidden; }
|
.progress-track { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.12); overflow: hidden; }
|
||||||
.progress-fill { height: 100%; border-radius: inherit; background: var(--grad); transition: width 1s linear; }
|
.progress-fill { height: 100%; border-radius: inherit; background: var(--grad); transition: width 1s linear; }
|
||||||
.progress-times { display: flex; justify-content: space-between; margin-top: 0.3rem; font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
.progress-times { display: flex; justify-content: space-between; margin-top: 0.3rem; font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
@media (prefers-reduced-motion: reduce) { .progress-fill { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .progress-fill { transition: none; } }
|
||||||
.track-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: 0.8rem; }
|
.track-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: 0.8rem; }
|
||||||
.service-link {
|
.service-link {
|
||||||
font-size: 0.76rem; color: var(--text); text-decoration: none; padding: 0.2rem 0.55rem; border-radius: 999px;
|
font-size: 0.76rem; color: var(--text); text-decoration: none; padding: 0.25rem 0.7rem; border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.08); transition: background 0.2s, transform 0.25s var(--spring);
|
background: var(--surface-2); transition: background 0.2s, transform 0.25s var(--spring);
|
||||||
}
|
}
|
||||||
.service-link:hover { background: rgba(255, 255, 255, 0.16); transform: translateY(-1px); }
|
.service-link:hover { background: #4a4868; transform: translateY(-1px); }
|
||||||
.service-link.inexact { opacity: 0.55; }
|
.service-link.inexact { opacity: 0.55; }
|
||||||
|
|
||||||
.profile-header { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.75rem; padding: 0.5rem 0 0.75rem; }
|
.profile-header { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.75rem; padding: 0.5rem 0 0.75rem; }
|
||||||
.profile-header h1 { font-size: 2.1rem; }
|
.profile-header h1 { font-size: 2.1rem; }
|
||||||
.profile-header .bio { margin: 0; max-width: 30rem; }
|
.profile-header .bio { margin: 0; max-width: 30rem; }
|
||||||
.stats { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
|
.stats { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
|
||||||
.stats span { padding: 0.3rem 0.8rem; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); font-size: 0.88rem; }
|
.stats span { padding: 0.3rem 0.9rem; border-radius: 999px; background: var(--surface-2); color: var(--muted); font-size: 0.88rem; }
|
||||||
.stats strong { color: var(--text); font-family: var(--font-display); }
|
.stats strong { color: var(--text); font-family: var(--font-display); }
|
||||||
.featured blockquote {
|
.featured blockquote {
|
||||||
margin: 0.9rem 0 0; padding: 0.6rem 0.9rem; border-left: 3px solid transparent; font-style: italic;
|
margin: 0.9rem 0 0; padding: 0.6rem 0.9rem; border-left: 3px solid transparent; font-style: italic;
|
||||||
border-image: var(--grad) 1; background: rgba(255, 255, 255, 0.04);
|
border-image: var(--grad) 1; background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search { position: relative; }
|
.search { position: relative; }
|
||||||
.search-results { list-style: none; margin: 0.5rem 0 0; padding: 0.4rem; background: var(--surface-solid); border: 1px solid var(--border); border-radius: 14px; }
|
.search-results { list-style: none; margin: 0.5rem 0 0; padding: 0.4rem; background: var(--surface-2); border-radius: 16px; box-shadow: var(--shadow); position: absolute; left: 0; right: 0; z-index: 25; }
|
||||||
.search-results li { padding: 0.4rem 0.45rem; border-radius: 10px; transition: background 0.15s; }
|
.search-results li { padding: 0.4rem 0.45rem; border-radius: 10px; transition: background 0.15s; }
|
||||||
.search-results li:hover { background: var(--surface-2); }
|
.search-results li:hover { background: #4a4868; }
|
||||||
.result { display: flex; gap: 0.6rem; align-items: center; background: none; border: 0; color: inherit; font: inherit; cursor: pointer; width: 100%; text-align: left; padding: 0; }
|
.result { display: flex; gap: 0.6rem; align-items: center; background: none; border: 0; color: inherit; font: inherit; cursor: pointer; width: 100%; text-align: left; padding: 0; }
|
||||||
.recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
.recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||||
.recent-row { display: flex; align-items: center; gap: 0.8rem; padding: 0.5rem 0.4rem; text-decoration: none; border-radius: 12px; transition: background 0.15s, transform 0.25s var(--spring); }
|
.recent-row { display: flex; align-items: center; gap: 0.8rem; padding: 0.5rem 0.4rem; text-decoration: none; border-radius: 12px; transition: background 0.15s, transform 0.25s var(--spring); }
|
||||||
@@ -368,11 +381,11 @@ a.recent-row:hover { background: var(--surface-2); transform: translateX(3px); }
|
|||||||
.recent li + li { border-top: 1px solid var(--border); }
|
.recent li + li { border-top: 1px solid var(--border); }
|
||||||
.recent-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
.recent-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||||
.recent-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.recent-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.recent-title { font-weight: 600; }
|
.recent-title { font-weight: 500; }
|
||||||
.recent-time { font-size: 0.8rem; white-space: nowrap; }
|
.recent-time { font-size: 0.8rem; white-space: nowrap; }
|
||||||
.artwork-sm.artwork-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); }
|
.artwork-sm.artwork-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); }
|
||||||
.qr-card { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; text-align: center; }
|
.qr-card { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; text-align: center; }
|
||||||
.qr { border-radius: 14px; box-shadow: 0 20px 50px -20px rgba(47, 140, 255, 0.6); }
|
.qr { border-radius: 16px; box-shadow: 0 20px 50px -20px rgba(255, 79, 139, 0.6); }
|
||||||
.credit { margin: 0.6rem 0 0; font-size: 0.8rem; }
|
.credit { margin: 0.6rem 0 0; font-size: 0.8rem; }
|
||||||
.picked { display: flex; gap: 0.75rem; align-items: center; }
|
.picked { display: flex; gap: 0.75rem; align-items: center; }
|
||||||
|
|
||||||
@@ -393,12 +406,12 @@ a.recent-row:hover { background: var(--surface-2); transform: translateX(3px); }
|
|||||||
.hero { padding: 4.5rem 0 3rem; display: flex; flex-direction: column; gap: 1.25rem; }
|
.hero { padding: 4.5rem 0 3rem; display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
.hero h1 { font-size: 3.2rem; letter-spacing: -0.04em; line-height: 1.02; }
|
.hero h1 { font-size: 3.2rem; letter-spacing: -0.04em; line-height: 1.02; }
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
background: linear-gradient(90deg, #a78bfa, #7dd3fc, #60a5fa, #a78bfa); background-size: 300% 100%;
|
background: linear-gradient(90deg, #ff4f8b, #e27ad8, #8fc8ff, #ff4f8b); background-size: 300% 100%;
|
||||||
-webkit-background-clip: text; background-clip: text; color: transparent; animation: shimmer 8s linear infinite;
|
-webkit-background-clip: text; background-clip: text; color: transparent; animation: shimmer 8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes shimmer { to { background-position: 300% 0; } }
|
@keyframes shimmer { to { background-position: 300% 0; } }
|
||||||
.hero p { color: var(--muted); font-size: 1.15rem; margin: 0; max-width: 34rem; }
|
.hero p { color: var(--muted); font-size: 1.15rem; margin: 0; max-width: 34rem; }
|
||||||
.hero .eq { height: 42px; gap: 5px; color: #8b9dff; }
|
.hero .eq { height: 42px; gap: 5px; color: var(--accent); }
|
||||||
.hero .eq span { width: 8px; border-radius: 4px; }
|
.hero .eq span { width: 8px; border-radius: 4px; }
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
@@ -410,3 +423,68 @@ a.recent-row:hover { background: var(--surface-2); transform: translateX(3px); }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Signed in on wide screens: sidebar | page in a rounded panel | your column (components/Shell.tsx). */
|
||||||
|
.sidebar, .rail { display: none; }
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
body:has(.shell) .nav, body:has(.shell) .tabbar { display: none; }
|
||||||
|
.shell { display: grid; grid-template-columns: 232px minmax(0, 1fr); min-height: 100vh; }
|
||||||
|
.shell-main { min-width: 0; padding: 1rem 1rem 0 0; display: flex; flex-direction: column; }
|
||||||
|
/* The page sits in a lighter panel; cards inside it are a shade darker, like the reference. */
|
||||||
|
.main-panel { --card: #2c2b45; --surface-2: #3b395a; flex: 1; background: var(--panel); border-radius: 36px; padding: 0.5rem 0; }
|
||||||
|
.main-panel .container { max-width: 760px; padding: 2rem 2rem 3rem; }
|
||||||
|
.shell .footer { padding: 1.25rem 1rem 1.5rem; }
|
||||||
|
|
||||||
|
.sidebar { display: flex; flex-direction: column; gap: 2rem; position: sticky; top: 0; height: 100vh; padding: 2.2rem 1.1rem 1.6rem; }
|
||||||
|
.sidebar-brand { font-family: var(--font-display); font-weight: 600; font-size: 1.45rem; text-decoration: none; color: #b7b4ff; padding-left: 0.9rem; letter-spacing: -0.01em; }
|
||||||
|
.sidebar-brand span { color: var(--accent); }
|
||||||
|
.sidebar-nav { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.sidebar-nav a, .sidebar-logout {
|
||||||
|
display: flex; align-items: center; gap: 0.85rem; padding: 0.75rem 0.95rem; border-radius: 16px; text-decoration: none;
|
||||||
|
color: var(--muted); font-weight: 400; transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.sidebar-nav a:hover { color: var(--text); background: rgba(255, 255, 255, 0.04); }
|
||||||
|
.sidebar-nav a.active { color: var(--accent); background: var(--panel); font-weight: 500; }
|
||||||
|
.sidebar-badge { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent); color: #fff; font-size: 0.7rem; font-weight: 600; display: grid; place-items: center; }
|
||||||
|
.sidebar-logout { margin-top: auto; background: none; border: 0; font: inherit; font-size: 0.88rem; cursor: pointer; }
|
||||||
|
.sidebar-logout:hover { color: var(--text); }
|
||||||
|
}
|
||||||
|
@media (min-width: 1240px) {
|
||||||
|
.shell { grid-template-columns: 232px minmax(0, 1fr) 330px; }
|
||||||
|
.shell-main { padding-right: 0; }
|
||||||
|
.rail { display: flex; flex-direction: column; gap: 1.6rem; position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 2rem 1.6rem; }
|
||||||
|
.rail-me { display: flex; align-items: center; gap: 0.75rem; padding-bottom: 1.3rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.rail-me .bell { margin: 0 0 0 auto; }
|
||||||
|
.rail-me-link { display: flex; align-items: center; gap: 0.75rem; min-width: 0; text-decoration: none; }
|
||||||
|
.rail-me-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.35; }
|
||||||
|
.rail-me-text strong { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rail-me-text .muted { font-size: 0.8rem; }
|
||||||
|
.rail-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
|
||||||
|
.rail-head h2 { margin: 0; font-size: 1.1rem; }
|
||||||
|
.pill-link { font-size: 0.78rem; color: var(--muted); text-decoration: none; padding: 0.3rem 0.8rem; border-radius: 999px; background: var(--panel); }
|
||||||
|
.pill-link:hover { color: var(--text); }
|
||||||
|
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||||
|
.rail-list li + li { border-top: 1px solid var(--border); }
|
||||||
|
.rail-row { display: flex; align-items: center; gap: 0.8rem; padding: 0.75rem 0.2rem; text-decoration: none; border-radius: 12px; transition: background 0.15s; }
|
||||||
|
.rail-row:hover { background: rgba(255, 255, 255, 0.03); }
|
||||||
|
.rail-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.35; }
|
||||||
|
.rail-row-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rail-row-title { font-size: 0.88rem; }
|
||||||
|
.rail-row-text .muted { font-size: 0.74rem; }
|
||||||
|
.rail-list li.unread .rail-row-title { font-weight: 500; }
|
||||||
|
.rail-row-end { font-size: 0.8rem; color: var(--muted); white-space: nowrap; }
|
||||||
|
.rail-art { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--panel); }
|
||||||
|
.rail-art-empty { display: grid; place-items: center; color: var(--muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The feed's greeting and search ("Hi Chris, welcome back"). */
|
||||||
|
.page-head { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.page-head-text { display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap; flex: 1; min-width: 0; }
|
||||||
|
.page-head h1 { font-size: 1.45rem; }
|
||||||
|
.page-head .muted { font-size: 1rem; }
|
||||||
|
.page-head .search { flex: 0 1 280px; min-width: 200px; }
|
||||||
|
.page-head .search input { border-radius: 999px; padding-left: 1.1rem; }
|
||||||
|
@media (max-width: 640px) { .page-head .search { flex-basis: 100%; } }
|
||||||
|
.page-section { font-size: 1.15rem; margin: 0.5rem 0 0; }
|
||||||
|
input[type='checkbox'], input[type='radio'] { width: auto; padding: 0; accent-color: var(--accent); box-shadow: none; }
|
||||||
|
.services-picker label.checkbox { white-space: nowrap; }
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Inter, Space_Grotesk } from 'next/font/google';
|
import { Poppins } from 'next/font/google';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Nav } from '@/components/Nav';
|
import { Shell } from '@/components/Shell';
|
||||||
import { Providers } from '@/components/Providers';
|
import { Providers } from '@/components/Providers';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
const poppins = Poppins({ subsets: ['latin'], weight: ['400', '500', '600', '700'], variable: '--font-poppins', display: 'swap' });
|
||||||
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap' });
|
|
||||||
|
|
||||||
// Previews need absolute image URLs. In production the API lives at <site>/api, so its origin is the site's.
|
// Previews need absolute image URLs. In production the API lives at <site>/api, so its origin is the site's.
|
||||||
const siteUrl = process.env.SITE_URL ?? new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3737').origin;
|
const siteUrl = process.env.SITE_URL ?? new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3737').origin;
|
||||||
@@ -21,23 +20,26 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: '#07070b',
|
themeColor: '#2a2942',
|
||||||
viewportFit: 'cover',
|
viewportFit: 'cover',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable} ${spaceGrotesk.variable}`}>
|
<html lang="en" className={poppins.variable}>
|
||||||
<body>
|
<body>
|
||||||
<div className="ambient" aria-hidden />
|
|
||||||
<Providers>
|
<Providers>
|
||||||
<Nav />
|
<Shell
|
||||||
<main className="container">{children}</main>
|
footer={
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<Link href="/privacy">Privacy</Link>
|
<Link href="/privacy">Privacy</Link>
|
||||||
<Link href="/terms">Terms</Link>
|
<Link href="/terms">Terms</Link>
|
||||||
<a href="mailto:app@musicshare.binarygnome.com">Contact</a>
|
<a href="mailto:app@musicshare.binarygnome.com">Contact</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Shell>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
scope: '/',
|
scope: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
background_color: '#07070b',
|
background_color: '#2a2942',
|
||||||
theme_color: '#07070b',
|
theme_color: '#2a2942',
|
||||||
categories: ['music', 'social'],
|
categories: ['music', 'social'],
|
||||||
icons: [
|
icons: [
|
||||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
|
|||||||
import { Avatar } from '@/components/Avatar';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { client, useMe } from '@/lib/api';
|
import { client, useMe } from '@/lib/api';
|
||||||
|
|
||||||
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
/** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const { me, loggedOut } = useMe();
|
const { me, loggedOut } = useMe();
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Avatar } from '@/components/Avatar';
|
|||||||
import { Equalizer } from '@/components/Equalizer';
|
import { Equalizer } from '@/components/Equalizer';
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||||
import { SharingControls } from '@/components/SharingControls';
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
|
import { UserSearch } from '@/components/UserSearch';
|
||||||
import { api, fetcher, useMe } from '@/lib/api';
|
import { api, fetcher, useMe } from '@/lib/api';
|
||||||
import { useRealtime } from '@/lib/useRealtime';
|
import { useRealtime } from '@/lib/useRealtime';
|
||||||
|
|
||||||
@@ -63,14 +64,21 @@ function Feed() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
<header className="page-head">
|
||||||
|
<div className="page-head-text">
|
||||||
|
<h1>Hi {me?.displayName ?? me?.username},</h1>
|
||||||
|
<span className="muted">Welcome back</span>
|
||||||
|
</div>
|
||||||
|
<UserSearch placeholder="Search friends" />
|
||||||
|
</header>
|
||||||
<SharingControls compact />
|
<SharingControls compact />
|
||||||
{mine?.nowPlaying && (
|
{mine?.nowPlaying && (
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<h2 className="section-label">You</h2>
|
<h2 className="section-label">Your song</h2>
|
||||||
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
<NowPlayingCard np={mine.nowPlaying} preferred={me?.preferredService} showUser={false} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<h1>Friends listening</h1>
|
<h2 className="page-section">Friends listening</h2>
|
||||||
{!me?.preferredService && (
|
{!me?.preferredService && (
|
||||||
<p className="notice">
|
<p className="notice">
|
||||||
Pick your streaming service in <Link href="/settings">Settings</Link> so songs open in the right app.
|
Pick your streaming service in <Link href="/settings">Settings</Link> so songs open in the right app.
|
||||||
@@ -82,16 +90,16 @@ function Feed() {
|
|||||||
) : (
|
) : (
|
||||||
<p className="muted">Nothing yet. <Link href="/friends">Find friends</Link>, and their music will show up here live.</p>
|
<p className="muted">Nothing yet. <Link href="/friends">Find friends</Link>, and their music will show up here live.</p>
|
||||||
))}
|
))}
|
||||||
{/* Cards rise in on load and glide to new positions when someone pins or starts playing. */}
|
{/* Cards fade in and glide to new positions when someone pins or starts playing. */}
|
||||||
<AnimatePresence initial={true}>
|
<AnimatePresence initial={true}>
|
||||||
{feed?.map((np, i) => (
|
{feed?.map((np) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={np.user.id}
|
key={np.user.id}
|
||||||
layout
|
layout
|
||||||
initial={{ opacity: 0, y: 24, scale: 0.98 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.96 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ type: 'spring', stiffness: 380, damping: 32, delay: Math.min(i, 8) * 0.05 }}
|
transition={{ duration: 0.2, layout: { duration: 0.25, ease: 'easeOut' } }}
|
||||||
>
|
>
|
||||||
<NowPlayingCard
|
<NowPlayingCard
|
||||||
np={np}
|
np={np}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { ReportForm } from '@/components/ProfileMenu';
|
||||||
|
import { Reactions } from '@/components/Reactions';
|
||||||
|
import { TrackCard } from '@/components/TrackCard';
|
||||||
|
import { client, fetcher, useMe } from '@/lib/api';
|
||||||
|
|
||||||
|
/** One play and its comments: where the 💬 link and comment notifications lead. */
|
||||||
|
export default function PlayPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { me, loggedOut } = useMe();
|
||||||
|
// Polled while open so other people's comments show up without reloading.
|
||||||
|
const { data, error, mutate } = useSWR<CommentThreadDTO>(me ? `/listens/${id}/comments` : null, fetcher, { refreshInterval: 5000 });
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [problem, setProblem] = useState<string | null>(null);
|
||||||
|
const [reporting, setReporting] = useState<CommentDTO | null>(null);
|
||||||
|
|
||||||
|
if (loggedOut) {
|
||||||
|
return (
|
||||||
|
<p className="muted">
|
||||||
|
<Link href="/login">Log in</Link> to see the comments.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) return <p className="muted">That play is gone.</p>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const owner = data.owner.displayName ?? data.owner.username;
|
||||||
|
|
||||||
|
const send = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setSending(true);
|
||||||
|
setProblem(null);
|
||||||
|
try {
|
||||||
|
const comment = await client.addComment(id, { body });
|
||||||
|
setDraft('');
|
||||||
|
await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
setProblem((err as Error).message);
|
||||||
|
}
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (c: CommentDTO) => {
|
||||||
|
if (!confirm('Delete this comment?')) return;
|
||||||
|
try {
|
||||||
|
await client.deleteComment(c.id);
|
||||||
|
await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
|
||||||
|
} catch (err) {
|
||||||
|
setProblem((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<p className="muted played-by">
|
||||||
|
<Link href={`/u/${data.owner.username}`} className="played-by-user">
|
||||||
|
<Avatar user={data.owner} size={28} />
|
||||||
|
<strong>{owner}</strong>
|
||||||
|
</Link>{' '}
|
||||||
|
played this · {timeAgo(data.playedAt)}
|
||||||
|
</p>
|
||||||
|
<section className="card">
|
||||||
|
<TrackCard track={data.track} preferred={me?.preferredService} />
|
||||||
|
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card comments">
|
||||||
|
<h2>Comments</h2>
|
||||||
|
{data.comments.length === 0 && <p className="muted">{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</p>}
|
||||||
|
{data.comments.map((c) => (
|
||||||
|
<div key={c.id} className="comment">
|
||||||
|
<Link href={`/u/${c.user.username}`}>
|
||||||
|
<Avatar user={c.user} size={34} />
|
||||||
|
</Link>
|
||||||
|
<div className="comment-main">
|
||||||
|
<div className="muted small">
|
||||||
|
<Link href={`/u/${c.user.username}`}>
|
||||||
|
<strong>{c.user.displayName ?? c.user.username}</strong>
|
||||||
|
</Link>{' '}
|
||||||
|
· {timeAgo(c.createdAt)}
|
||||||
|
</div>
|
||||||
|
<p className="comment-body">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
<div className="comment-actions">
|
||||||
|
{c.canDelete && (
|
||||||
|
<button type="button" className="link-button" onClick={() => remove(c)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{c.user.id !== me?.id && (
|
||||||
|
<button type="button" className="link-button" onClick={() => setReporting(c)}>
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.canComment ? (
|
||||||
|
<form className="comment-form" onSubmit={send}>
|
||||||
|
<textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter posts; Shift+Enter is a new line.
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
|
||||||
|
maxLength={COMMENT_MAX_LENGTH}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" disabled={!draft.trim() || sending}>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="muted small">Only people {owner} follows can comment on their songs.</p>
|
||||||
|
)}
|
||||||
|
{problem && <p className="error">{problem}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{reporting && (
|
||||||
|
<ReportForm
|
||||||
|
username={reporting.user.username}
|
||||||
|
name={reporting.user.displayName ?? reporting.user.username}
|
||||||
|
commentId={reporting.id}
|
||||||
|
onClose={() => setReporting(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
|
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
|
||||||
|
|
||||||
const UPDATED = 'October 14, 2026';
|
const UPDATED = 'October 15, 2026';
|
||||||
const CONTACT = 'app@musicshare.binarygnome.com';
|
const CONTACT = 'app@musicshare.binarygnome.com';
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
@@ -40,11 +40,16 @@ export default function PrivacyPage() {
|
|||||||
“notification access”, but the app only reads the song, artist and album from music apps you allow.
|
“notification access”, but the app only reads the song, artist and album from music apps you allow.
|
||||||
It doesn't read or store your notifications, messages or anything from other apps.
|
It doesn't read or store your notifications, messages or anything from other apps.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Comments and reactions</strong> you leave on plays. Comments stay until you or the person who played the
|
||||||
|
song deletes them, or one of your accounts is deleted. Plays with comments are kept in their history so the
|
||||||
|
conversation isn't lost.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>People:</strong> who you follow, who you've pinned, who you've blocked, and reports you send.
|
<strong>People:</strong> who you follow, who you've pinned, who you've blocked, and reports you send.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Notifications:</strong> reactions, new followers and songs (with any note) sent to you, and your
|
<strong>Notifications:</strong> reactions, comments and replies, new followers and songs (with any note) sent to you, and your
|
||||||
notification settings. If you turn on push notifications, we store the push address your browser or
|
notification settings. If you turn on push notifications, we store the push address your browser or
|
||||||
phone gives us and its encryption keys.
|
phone gives us and its encryption keys.
|
||||||
</li>
|
</li>
|
||||||
@@ -63,13 +68,17 @@ export default function PrivacyPage() {
|
|||||||
people who aren't signed in.
|
people who aren't signed in.
|
||||||
</li>
|
</li>
|
||||||
<li>Your followers see what you play in their feed, live.</li>
|
<li>Your followers see what you play in their feed, live.</li>
|
||||||
|
<li>
|
||||||
|
Comments on a play can be read by signed-in people who can see that play. Only you and the people you follow can
|
||||||
|
write them, and you can delete any comment on your plays.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Your email address is never shown to other people. It's used to sign in, to send you confirmation and
|
Your email address is never shown to other people. It's used to sign in, to send you confirmation and
|
||||||
password reset emails, and to contact you about your account.
|
password reset emails, and to contact you about your account.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
People you block can't find you or see your profile, and you stop following each other. They aren't
|
People you block can't find you or see your profile, and you stop following each other. Their comments on
|
||||||
told.
|
your plays, and yours on theirs, are removed. They aren't told.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
You can <strong>pause sharing</strong> at any time in Settings. While paused, nothing new you play is shared or
|
You can <strong>pause sharing</strong> at any time in Settings. While paused, nothing new you play is shared or
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useState } from 'react';
|
|||||||
import { client, useMe } from '@/lib/api';
|
import { client, useMe } from '@/lib/api';
|
||||||
import { ActivityBell } from './ActivityBell';
|
import { ActivityBell } from './ActivityBell';
|
||||||
|
|
||||||
/** Top bar: logo and the four main places. On phones those move to a bottom tab bar. */
|
/** Top bar: logo and the four main places. On phones those move to a bottom tab bar; wide screens use Shell's sidebar. */
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
@@ -85,14 +85,14 @@ function VerifyBanner({ email }: { email: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const icon = { width: 22, height: 22, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' } as const;
|
const icon = { width: 22, height: 22, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' } as const;
|
||||||
const FeedIcon = () => (
|
export const FeedIcon = () => (
|
||||||
<svg {...icon} aria-hidden>
|
<svg {...icon} aria-hidden>
|
||||||
<path d="M9 18V5l12-2v13" />
|
<path d="M9 18V5l12-2v13" />
|
||||||
<circle cx="6" cy="18" r="3" />
|
<circle cx="6" cy="18" r="3" />
|
||||||
<circle cx="18" cy="16" r="3" />
|
<circle cx="18" cy="16" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
const FriendsIcon = () => (
|
export const FriendsIcon = () => (
|
||||||
<svg {...icon} aria-hidden>
|
<svg {...icon} aria-hidden>
|
||||||
<circle cx="9" cy="8" r="3.5" />
|
<circle cx="9" cy="8" r="3.5" />
|
||||||
<path d="M2 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
|
<path d="M2 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
|
||||||
@@ -100,13 +100,13 @@ const FriendsIcon = () => (
|
|||||||
<path d="M18 14c2.4.6 4 2.6 4 6" />
|
<path d="M18 14c2.4.6 4 2.6 4 6" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
const ProfileIcon = () => (
|
export const ProfileIcon = () => (
|
||||||
<svg {...icon} aria-hidden>
|
<svg {...icon} aria-hidden>
|
||||||
<circle cx="12" cy="8" r="4" />
|
<circle cx="12" cy="8" r="4" />
|
||||||
<path d="M4 21c0-4 3.6-7 8-7s8 3 8 7" />
|
<path d="M4 21c0-4 3.6-7 8-7s8 3 8 7" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
const SettingsIcon = () => (
|
export const SettingsIcon = () => (
|
||||||
<svg {...icon} aria-hidden>
|
<svg {...icon} aria-hidden>
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" />
|
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" />
|
||||||
|
|||||||
@@ -26,22 +26,9 @@ export function NowPlayingCard({
|
|||||||
}) {
|
}) {
|
||||||
const sourceLabel = np.sourceApp ?? SERVICE_LABELS[np.source];
|
const sourceLabel = np.sourceApp ?? SERVICE_LABELS[np.source];
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
// Your own song gets the gradient "hero" card; friends' songs sit on plain panels.
|
||||||
return (
|
return (
|
||||||
<article className={`card np-card ${pinned ? 'pinned' : ''} ${np.isPlaying ? 'playing' : ''}`}>
|
<article className={`card np-card ${showUser ? '' : 'np-hero'} ${pinned ? 'pinned' : ''} ${np.isPlaying ? 'playing' : ''}`}>
|
||||||
{/* The card glows with its album art; crossfades when the song changes. */}
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{np.track.artworkUrl && (
|
|
||||||
<motion.div
|
|
||||||
key={np.track.artworkUrl}
|
|
||||||
className="np-backdrop"
|
|
||||||
style={{ backgroundImage: `url("${np.track.artworkUrl}")` }}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 0.6 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
{showUser && (
|
{showUser && (
|
||||||
<Link href={`/u/${np.user.username}`} className="user-chip">
|
<Link href={`/u/${np.user.username}`} className="user-chip">
|
||||||
@@ -89,7 +76,7 @@ export function NowPlayingCard({
|
|||||||
>
|
>
|
||||||
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
|
<TrackRow track={np.track} preferred={preferred} expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
|
||||||
<ProgressBar np={np} compact={!expanded} />
|
<ProgressBar np={np} compact={!expanded} />
|
||||||
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} />
|
<Reactions listenId={np.listenId} reactions={np.reactions} ownerId={np.user.id} commentCount={np.commentCount ?? 0} />
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export function ProfileMenu({ profile, onChanged }: { profile: ProfileDTO; onCha
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportForm({ username, name, onClose }: { username: string; name: string; onClose: () => void }) {
|
/** Report someone's account, or one of their comments (`commentId`). */
|
||||||
|
export function ReportForm({ username, name, commentId, onClose }: { username: string; name: string; commentId?: string; onClose: () => void }) {
|
||||||
const [reason, setReason] = useState<ReportReason | null>(null);
|
const [reason, setReason] = useState<ReportReason | null>(null);
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
@@ -72,7 +73,8 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!reason) return;
|
if (!reason) return;
|
||||||
try {
|
try {
|
||||||
await client.report(username, { reason, note: note.trim() || null });
|
const input = { reason, note: note.trim() || null };
|
||||||
|
await (commentId ? client.reportComment(commentId, input) : client.report(username, input));
|
||||||
setSent(true);
|
setSent(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -90,7 +92,7 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Modal onClose={onClose} title={`Report ${name}`}>
|
<Modal onClose={onClose} title={commentId ? `Report ${name}'s comment` : `Report ${name}`}>
|
||||||
<form className="form" onSubmit={submit}>
|
<form className="form" onSubmit={submit}>
|
||||||
<fieldset className="choices">
|
<fieldset className="choices">
|
||||||
<legend className="muted">What's wrong?</legend>
|
<legend className="muted">What's wrong?</legend>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
|
||||||
import { client, useMe } from '@/lib/api';
|
import { client, useMe } from '@/lib/api';
|
||||||
@@ -9,7 +10,18 @@ import { Modal } from './ProfileMenu';
|
|||||||
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
|
||||||
* offers the quick six, and "More" opens the full picker. One reaction per person.
|
* offers the quick six, and "More" opens the full picker. One reaction per person.
|
||||||
*/
|
*/
|
||||||
export function Reactions({ listenId, reactions, ownerId }: { listenId: string | null; reactions: ReactionDTO[]; ownerId: string }) {
|
export function Reactions({
|
||||||
|
listenId,
|
||||||
|
reactions,
|
||||||
|
ownerId,
|
||||||
|
commentCount,
|
||||||
|
}: {
|
||||||
|
listenId: string | null;
|
||||||
|
reactions: ReactionDTO[];
|
||||||
|
ownerId: string;
|
||||||
|
/** Shows a 💬 link to the play's comments. */
|
||||||
|
commentCount?: number;
|
||||||
|
}) {
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
|
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
|
||||||
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
|
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
|
||||||
@@ -27,7 +39,8 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
|
|||||||
}, [quickOpen]);
|
}, [quickOpen]);
|
||||||
|
|
||||||
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
const canReact = Boolean(me && listenId && me.id !== ownerId);
|
||||||
if (!listenId || (!canReact && shown.length === 0)) return null;
|
const showComments = commentCount !== undefined && Boolean(me);
|
||||||
|
if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
|
||||||
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
|
||||||
|
|
||||||
const choose = async (emoji: string) => {
|
const choose = async (emoji: string) => {
|
||||||
@@ -77,6 +90,16 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showComments && (
|
||||||
|
<Link
|
||||||
|
href={`/play/${listenId}`}
|
||||||
|
className="reaction"
|
||||||
|
aria-label={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
|
||||||
|
>
|
||||||
|
<span className="reaction-emoji">💬</span>
|
||||||
|
{commentCount ? <span className="reaction-count">{commentCount}</span> : null}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{error && <span className="error">{error}</span>}
|
{error && <span className="error">{error}</span>}
|
||||||
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
|
{pickerOpen && <EmojiPicker onPick={choose} onClose={() => setPickerOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function RecentPlays({
|
|||||||
) : (
|
) : (
|
||||||
<div className="recent-row">{row}</div>
|
<div className="recent-row">{row}</div>
|
||||||
)}
|
)}
|
||||||
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} />
|
<Reactions listenId={p.listenId} reactions={p.reactions} ownerId={ownerId} commentCount={p.commentCount ?? 0} />
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { notificationPath, notificationText, timeAgo, type MeDTO, type NotificationListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||||
|
import { client, fetcher, useMe } from '@/lib/api';
|
||||||
|
import { ActivityBell } from './ActivityBell';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { FeedIcon, FriendsIcon, Nav, ProfileIcon, SettingsIcon } from './Nav';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed in on a wide screen: a sidebar on the left, the page in a rounded panel in the middle, and your
|
||||||
|
* profile, recent activity and recent plays on the right. Narrower screens drop the right column, then
|
||||||
|
* fall back to the top bar and bottom tabs (Nav).
|
||||||
|
*/
|
||||||
|
export function Shell({ children, footer }: { children: React.ReactNode; footer: React.ReactNode }) {
|
||||||
|
const { me } = useMe();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
<div className={me ? 'shell' : undefined}>
|
||||||
|
{me && <Sidebar me={me} />}
|
||||||
|
<div className="shell-main">
|
||||||
|
<main className={me ? 'main-panel' : 'container'}>{me ? <div className="container">{children}</div> : children}</main>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
{me && <RightRail me={me} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ me }: { me: MeDTO }) {
|
||||||
|
const path = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { data } = useSWR<NotificationListDTO>('/notifications', fetcher, { refreshInterval: 60_000 });
|
||||||
|
const unread = data?.unread ?? 0;
|
||||||
|
const items = [
|
||||||
|
{ href: '/', label: 'Feed', icon: <FeedIcon />, active: path === '/' },
|
||||||
|
{ href: '/friends', label: 'Friends', icon: <FriendsIcon />, active: path.startsWith('/friends') },
|
||||||
|
{ href: '/notifications', label: 'Activity', icon: <BellIcon />, active: path.startsWith('/notifications'), badge: unread },
|
||||||
|
{ href: `/u/${me.username}`, label: 'Profile', icon: <ProfileIcon />, active: path.startsWith(`/u/${me.username}`) },
|
||||||
|
{ href: '/settings', label: 'Settings', icon: <SettingsIcon />, active: path.startsWith('/settings') || path === '/admin' },
|
||||||
|
];
|
||||||
|
const { mutate } = useMe();
|
||||||
|
const logOut = async () => {
|
||||||
|
await client.logout();
|
||||||
|
await mutate(undefined, { revalidate: true });
|
||||||
|
router.push('/');
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<aside className="sidebar" aria-label="Main">
|
||||||
|
<Link href="/" className="sidebar-brand">
|
||||||
|
musicshare<span>.</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{items.map((i) => (
|
||||||
|
<Link key={i.href} href={i.href} className={i.active ? 'active' : ''} aria-current={i.active ? 'page' : undefined}>
|
||||||
|
{i.icon}
|
||||||
|
<span>{i.label}</span>
|
||||||
|
{i.badge ? <span className="sidebar-badge">{i.badge > 99 ? '99+' : i.badge}</span> : null}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<button type="button" className="sidebar-logout" onClick={logOut}>
|
||||||
|
<LogOutIcon />
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RightRail({ me }: { me: MeDTO }) {
|
||||||
|
const { data: activity } = useSWR<NotificationListDTO>('/notifications', fetcher, { refreshInterval: 60_000 });
|
||||||
|
const { data: profile } = useSWR<ProfileDTO>(`/users/${me.username}`, fetcher);
|
||||||
|
const name = me.displayName ?? me.username;
|
||||||
|
return (
|
||||||
|
<aside className="rail" aria-label="You">
|
||||||
|
<div className="rail-me">
|
||||||
|
<Link href={`/u/${me.username}`} className="rail-me-link">
|
||||||
|
<Avatar user={me} size={46} />
|
||||||
|
<span className="rail-me-text">
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<span className="muted">@{me.username}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<ActivityBell />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rail-section">
|
||||||
|
<header className="rail-head">
|
||||||
|
<h2>Activity</h2>
|
||||||
|
<Link href="/notifications" className="pill-link">
|
||||||
|
See all
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
{activity?.notifications.length === 0 && <p className="muted small">Reactions, comments and new followers show up here.</p>}
|
||||||
|
<ul className="rail-list">
|
||||||
|
{activity?.notifications.slice(0, 5).map((n) => {
|
||||||
|
const { title, body } = notificationText(n);
|
||||||
|
return (
|
||||||
|
<li key={n.id} className={n.read ? '' : 'unread'}>
|
||||||
|
<Link href={notificationPath(n)} className="rail-row">
|
||||||
|
<Avatar user={n.actor} size={38} />
|
||||||
|
<span className="rail-row-text">
|
||||||
|
<span className="rail-row-title">{title}</span>
|
||||||
|
<span className="muted">{body || timeAgo(n.createdAt)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="rail-row-end">{n.emoji ?? (n.type === 'comment' || n.type === 'reply' ? '💬' : timeAgo(n.createdAt))}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{profile && profile.recent.length > 0 && (
|
||||||
|
<section className="rail-section">
|
||||||
|
<header className="rail-head">
|
||||||
|
<h2>Your recent plays</h2>
|
||||||
|
</header>
|
||||||
|
<ul className="rail-list">
|
||||||
|
{profile.recent.slice(0, 5).map((p) => (
|
||||||
|
<li key={p.listenId}>
|
||||||
|
<Link href={`/play/${p.listenId}`} className="rail-row">
|
||||||
|
{p.track.artworkUrl ? (
|
||||||
|
<img className="rail-art" src={p.track.artworkUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span className="rail-art rail-art-empty">♪</span>
|
||||||
|
)}
|
||||||
|
<span className="rail-row-text">
|
||||||
|
<span className="rail-row-title">{p.track.title}</span>
|
||||||
|
<span className="muted">{p.track.artist}</span>
|
||||||
|
</span>
|
||||||
|
<span className="rail-row-end">{timeAgo(p.playedAt)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' } as const;
|
||||||
|
const BellIcon = () => (
|
||||||
|
<svg {...icon} aria-hidden>
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const LogOutIcon = () => (
|
||||||
|
<svg {...icon} aria-hidden>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<path d="M16 17l5-5-5-5" />
|
||||||
|
<path d="M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -8,7 +8,7 @@ import { fetcher } from '@/lib/api';
|
|||||||
import { useDebounced } from '@/lib/useDebounced';
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
export function UserSearch() {
|
export function UserSearch({ placeholder = 'Find friends by username' }: { placeholder?: string }) {
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const debounced = useDebounced(q.trim(), 250);
|
const debounced = useDebounced(q.trim(), 250);
|
||||||
const { data } = useSWR<UserSummaryDTO[]>(
|
const { data } = useSWR<UserSummaryDTO[]>(
|
||||||
@@ -17,7 +17,7 @@ export function UserSearch() {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="search">
|
<div className="search">
|
||||||
<input placeholder="Find friends by username" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input placeholder={placeholder} aria-label="Search people" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
{data && debounced.length >= 2 && (
|
{data && debounced.length >= 2 && (
|
||||||
<ul className="search-results">
|
<ul className="search-results">
|
||||||
{data.length === 0 && <li className="muted">No one found</li>}
|
{data.length === 0 && <li className="muted">No one found</li>}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Comments: tap 💬 next to a song's reactions to talk about it. The person who played it and the people they follow can comment, and you'll be notified about comments and replies. Changing your profile picture works again, and lyrics load more reliably.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A fresh new look: softer slate panels, pink accents, a new font, and your own song on a colourful gradient card. The feed greets you, and the bell and tabs are cleaner.
|
||||||
Generated
+19
-5
@@ -809,10 +809,10 @@
|
|||||||
},
|
},
|
||||||
"apps/mobile": {
|
"apps/mobile": {
|
||||||
"name": "@musicshare/mobile",
|
"name": "@musicshare/mobile",
|
||||||
"version": "1.2.0",
|
"version": "1.4.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
"@expo-google-fonts/poppins": "^0.4.1",
|
||||||
"@expo/ui": "~57.0.21",
|
"@expo/ui": "~57.0.21",
|
||||||
"@musicshare/shared": "*",
|
"@musicshare/shared": "*",
|
||||||
"expo": "~57.0.26",
|
"expo": "~57.0.26",
|
||||||
@@ -820,10 +820,12 @@
|
|||||||
"expo-camera": "~57.0.6",
|
"expo-camera": "~57.0.6",
|
||||||
"expo-constants": "~57.0.20",
|
"expo-constants": "~57.0.20",
|
||||||
"expo-device": "~57.0.2",
|
"expo-device": "~57.0.2",
|
||||||
|
"expo-file-system": "~57.0.7",
|
||||||
"expo-font": "~57.0.4",
|
"expo-font": "~57.0.4",
|
||||||
"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-manipulator": "~57.0.21",
|
||||||
"expo-image-picker": "~57.0.20",
|
"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",
|
||||||
@@ -3189,10 +3191,10 @@
|
|||||||
"integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==",
|
"integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==",
|
||||||
"license": "MIT AND Apache-2.0"
|
"license": "MIT AND Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@expo-google-fonts/space-grotesk": {
|
"node_modules/@expo-google-fonts/poppins": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@expo-google-fonts/space-grotesk/-/space-grotesk-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@expo-google-fonts/poppins/-/poppins-0.4.1.tgz",
|
||||||
"integrity": "sha512-ZVQYw4Ok/pgcSJiufP8oRZE3AVxS9xtmKEUfsurbHkHNdMc/GA1gDXP9G4Cr7KL4KqSc0haexR2TuMigotCn4Q==",
|
"integrity": "sha512-LiX5oEryJqDt3UmWwr4oqf11hGMz/JEffkkNcZ3UtGwyoMFzXmBmTwcrAWrEttxypxmLNmPykDg+CY2VjKve9A==",
|
||||||
"license": "MIT AND OFL-1.1"
|
"license": "MIT AND OFL-1.1"
|
||||||
},
|
},
|
||||||
"node_modules/@expo/cli": {
|
"node_modules/@expo/cli": {
|
||||||
@@ -11288,6 +11290,18 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-image-manipulator": {
|
||||||
|
"version": "57.0.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.21.tgz",
|
||||||
|
"integrity": "sha512-NZeNvZxBNf/PK97Df5zORmtqRR/LovkA+lHdRLm1IJFiVKjs4xr+Ib6/jWhDxn04VOX3TJswCQyu4SCxkgMe4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-image-loader": "~57.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-image-picker": {
|
"node_modules/expo-image-picker": {
|
||||||
"version": "57.0.20",
|
"version": "57.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminReportDTO,
|
AdminReportDTO,
|
||||||
AdminStatsDTO,
|
AdminStatsDTO,
|
||||||
|
CommentDTO,
|
||||||
|
CommentThreadDTO,
|
||||||
ConnectionDTO,
|
ConnectionDTO,
|
||||||
FollowListDTO,
|
FollowListDTO,
|
||||||
ReactionDTO,
|
ReactionDTO,
|
||||||
@@ -17,6 +19,7 @@ import type { AppUpdateDTO } from './updates';
|
|||||||
import type {
|
import type {
|
||||||
ChangeEmailInput,
|
ChangeEmailInput,
|
||||||
ChangePasswordInput,
|
ChangePasswordInput,
|
||||||
|
CommentInput,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
RegisterInput,
|
RegisterInput,
|
||||||
ReportReason,
|
ReportReason,
|
||||||
@@ -136,6 +139,14 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
|
|||||||
react: (listenId: string, emoji: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }),
|
react: (listenId: string, emoji: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }),
|
||||||
unreact: (listenId: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }),
|
unreact: (listenId: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
/** A play with its comments. */
|
||||||
|
comments: (listenId: string) => request<CommentThreadDTO>(`/listens/${q(listenId)}/comments`),
|
||||||
|
addComment: (listenId: string, input: CommentInput) =>
|
||||||
|
request<CommentDTO>(`/listens/${q(listenId)}/comments`, { method: 'POST', body: input }),
|
||||||
|
deleteComment: (id: string) => request<{ ok: true }>(`/comments/${q(id)}`, { method: 'DELETE' }),
|
||||||
|
reportComment: (id: string, input: { reason: ReportReason; note?: string | null }) =>
|
||||||
|
request<{ ok: true }>(`/comments/${q(id)}/report`, { body: input }),
|
||||||
|
|
||||||
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
||||||
/** Null when no lyrics were found for the song. */
|
/** Null when no lyrics were found for the song. */
|
||||||
lyrics: (trackId: string) => request<LyricsDTO | null>(`/tracks/${q(trackId)}/lyrics`),
|
lyrics: (trackId: string) => request<LyricsDTO | null>(`/tracks/${q(trackId)}/lyrics`),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function item(username: string, opts: { pinnedAt?: string; isPlaying?: boolean;
|
|||||||
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
|
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
|
||||||
listenId: null,
|
listenId: null,
|
||||||
reactions: [],
|
reactions: [],
|
||||||
|
commentCount: 0,
|
||||||
pinnedAt: opts.pinnedAt ?? null,
|
pinnedAt: opts.pinnedAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ export interface NowPlayingDTO {
|
|||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
progressMs: number | null;
|
progressMs: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/** The play this is, for reactions (null for very old rows). */
|
/** The play this is, for reactions and comments (null for very old rows). */
|
||||||
listenId: string | null;
|
listenId: string | null;
|
||||||
reactions: ReactionDTO[];
|
reactions: ReactionDTO[];
|
||||||
|
commentCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A feed entry. pinnedAt is set when the viewer pinned this friend. */
|
/** A feed entry. pinnedAt is set when the viewer pinned this friend. */
|
||||||
@@ -90,6 +91,29 @@ export interface RecentPlayDTO {
|
|||||||
source: NowPlayingProvider;
|
source: NowPlayingProvider;
|
||||||
playedAt: string;
|
playedAt: string;
|
||||||
reactions: ReactionDTO[];
|
reactions: ReactionDTO[];
|
||||||
|
commentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A comment on a play. */
|
||||||
|
export interface CommentDTO {
|
||||||
|
id: string;
|
||||||
|
user: UserSummaryDTO;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
/** The viewer wrote it, owns the play, or is an admin. */
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A play and its comments, oldest first (GET /listens/:id/comments). */
|
||||||
|
export interface CommentThreadDTO {
|
||||||
|
listenId: string;
|
||||||
|
owner: UserSummaryDTO;
|
||||||
|
track: TrackDTO;
|
||||||
|
playedAt: string;
|
||||||
|
reactions: ReactionDTO[];
|
||||||
|
comments: CommentDTO[];
|
||||||
|
/** False when the viewer can read but not write (only the player and people they follow can comment). */
|
||||||
|
canComment: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileDTO {
|
export interface ProfileDTO {
|
||||||
@@ -119,6 +143,8 @@ export interface AdminReportDTO {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
reporter: UserSummaryDTO | null;
|
reporter: UserSummaryDTO | null;
|
||||||
reported: UserSummaryDTO & { suspended: boolean };
|
reported: UserSummaryDTO & { suspended: boolean };
|
||||||
|
/** When a comment was reported: what it said (kept even if it's since been deleted). */
|
||||||
|
comment: { id: string | null; body: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminStatsDTO {
|
export interface AdminStatsDTO {
|
||||||
|
|||||||
@@ -17,11 +17,22 @@ describe('notifications', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('links songs to the song and follows to the person', () => {
|
it('links songs to the song and follows to the person', () => {
|
||||||
expect(notificationPath({ type: 'share', actor, track })).toBe('/t/t1');
|
expect(notificationPath({ type: 'share', actor, track, listenId: null })).toBe('/t/t1');
|
||||||
expect(notificationPath({ type: 'follow', actor, track: null })).toBe('/u/bob');
|
expect(notificationPath({ type: 'follow', actor, track: null, listenId: null })).toBe('/u/bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the play for comments, replies and reactions', () => {
|
||||||
|
expect(notificationPath({ type: 'comment', actor, track, listenId: 'l1' })).toBe('/play/l1');
|
||||||
|
expect(notificationPath({ type: 'reaction', actor, track, listenId: 'l1' })).toBe('/play/l1');
|
||||||
|
// The play was trimmed from history: fall back to the song.
|
||||||
|
expect(notificationPath({ type: 'reaction', actor, track, listenId: null })).toBe('/t/t1');
|
||||||
|
expect(notificationText({ type: 'reply', actor, track, emoji: null, message: 'same' })).toEqual({
|
||||||
|
title: 'Bob replied on Teardrop by Massive Attack',
|
||||||
|
body: 'same',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats missing settings as on', () => {
|
it('treats missing settings as on', () => {
|
||||||
expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, follow: false, share: true });
|
expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, comment: true, reply: true, follow: false, share: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TrackDTO, UserSummaryDTO } from './dto';
|
import type { TrackDTO, UserSummaryDTO } from './dto';
|
||||||
|
|
||||||
export const NOTIFICATION_TYPES = ['reaction', 'follow', 'share'] as const;
|
export const NOTIFICATION_TYPES = ['reaction', 'comment', 'reply', 'follow', 'share'] as const;
|
||||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||||
|
|
||||||
/** Which notifications get pushed to your phone/browser. */
|
/** Which notifications get pushed to your phone/browser. */
|
||||||
@@ -8,6 +8,8 @@ export type NotificationPrefs = Record<NotificationType, boolean>;
|
|||||||
|
|
||||||
export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
|
export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
|
||||||
reaction: 'Reactions to your songs',
|
reaction: 'Reactions to your songs',
|
||||||
|
comment: 'Comments on your songs',
|
||||||
|
reply: 'Replies where you commented',
|
||||||
follow: 'New followers',
|
follow: 'New followers',
|
||||||
share: 'Songs sent to you',
|
share: 'Songs sent to you',
|
||||||
};
|
};
|
||||||
@@ -15,6 +17,8 @@ export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
|
|||||||
/** Missing settings mean on. */
|
/** Missing settings mean on. */
|
||||||
export const notificationPrefs = (stored: Partial<NotificationPrefs> | null | undefined): NotificationPrefs => ({
|
export const notificationPrefs = (stored: Partial<NotificationPrefs> | null | undefined): NotificationPrefs => ({
|
||||||
reaction: stored?.reaction ?? true,
|
reaction: stored?.reaction ?? true,
|
||||||
|
comment: stored?.comment ?? true,
|
||||||
|
reply: stored?.reply ?? true,
|
||||||
follow: stored?.follow ?? true,
|
follow: stored?.follow ?? true,
|
||||||
share: stored?.share ?? true,
|
share: stored?.share ?? true,
|
||||||
});
|
});
|
||||||
@@ -24,6 +28,8 @@ export interface NotificationDTO {
|
|||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
actor: UserSummaryDTO;
|
actor: UserSummaryDTO;
|
||||||
track: TrackDTO | null;
|
track: TrackDTO | null;
|
||||||
|
/** The play a reaction or comment was on, while it exists. */
|
||||||
|
listenId: string | null;
|
||||||
emoji: string | null;
|
emoji: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
@@ -47,6 +53,10 @@ export function notificationText(n: Pick<NotificationDTO, 'type' | 'actor' | 'tr
|
|||||||
switch (n.type) {
|
switch (n.type) {
|
||||||
case 'reaction':
|
case 'reaction':
|
||||||
return { title: `${who} reacted ${n.emoji ?? ''}`.trim(), body: `to ${song}` };
|
return { title: `${who} reacted ${n.emoji ?? ''}`.trim(), body: `to ${song}` };
|
||||||
|
case 'comment':
|
||||||
|
return { title: `${who} commented on ${song}`, body: n.message ?? '' };
|
||||||
|
case 'reply':
|
||||||
|
return { title: `${who} replied on ${song}`, body: n.message ?? '' };
|
||||||
case 'follow':
|
case 'follow':
|
||||||
return { title: `${who} followed you`, body: 'Follow back to see what they play.' };
|
return { title: `${who} followed you`, body: 'Follow back to see what they play.' };
|
||||||
case 'share':
|
case 'share':
|
||||||
@@ -55,7 +65,8 @@ export function notificationText(n: Pick<NotificationDTO, 'type' | 'actor' | 'tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Where tapping it goes, as an app/web path. */
|
/** Where tapping it goes, as an app/web path. */
|
||||||
export function notificationPath(n: Pick<NotificationDTO, 'type' | 'actor' | 'track'>): string {
|
export function notificationPath(n: Pick<NotificationDTO, 'type' | 'actor' | 'track' | 'listenId'>): string {
|
||||||
|
if ((n.type === 'comment' || n.type === 'reply' || n.type === 'reaction') && n.listenId) return `/play/${n.listenId}`;
|
||||||
if ((n.type === 'share' || n.type === 'reaction') && n.track) return `/t/${n.track.id}`;
|
if ((n.type === 'share' || n.type === 'reaction') && n.track) return `/t/${n.track.id}`;
|
||||||
return `/u/${n.actor.username}`;
|
return `/u/${n.actor.username}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const updateMeSchema = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
notifyPrefs: z
|
notifyPrefs: z
|
||||||
.object({ reaction: z.boolean(), follow: z.boolean(), share: z.boolean() })
|
.object({ reaction: z.boolean(), comment: z.boolean(), reply: z.boolean(), follow: z.boolean(), share: z.boolean() })
|
||||||
.partial()
|
.partial()
|
||||||
.optional(),
|
.optional(),
|
||||||
/** Mark onboarding as finished. */
|
/** Mark onboarding as finished. */
|
||||||
@@ -112,6 +112,12 @@ export const REPORT_REASON_LABELS: Record<ReportReason, string> = {
|
|||||||
impersonation: 'Pretending to be someone else',
|
impersonation: 'Pretending to be someone else',
|
||||||
other: 'Something else',
|
other: 'Something else',
|
||||||
};
|
};
|
||||||
|
export const COMMENT_MAX_LENGTH = 500;
|
||||||
|
export const commentSchema = z.object({
|
||||||
|
body: z.string().trim().min(1, 'Write something first').max(COMMENT_MAX_LENGTH, `Keep it under ${COMMENT_MAX_LENGTH} characters`),
|
||||||
|
});
|
||||||
|
export type CommentInput = z.infer<typeof commentSchema>;
|
||||||
|
|
||||||
export const reportSchema = z.object({
|
export const reportSchema = z.object({
|
||||||
reason: z.enum(REPORT_REASONS),
|
reason: z.enum(REPORT_REASONS),
|
||||||
note: z.string().trim().max(500).nullish(),
|
note: z.string().trim().max(500).nullish(),
|
||||||
|
|||||||
Reference in New Issue
Block a user