diff --git a/apps/api/src/routes/portfolios.ts b/apps/api/src/routes/portfolios.ts index cdc3322..5daf0ec 100644 --- a/apps/api/src/routes/portfolios.ts +++ b/apps/api/src/routes/portfolios.ts @@ -2,8 +2,10 @@ import { PORTFOLIO_ACCENT_KEYS, PORTFOLIO_CARDS_MAX, PORTFOLIO_DESCRIPTION_MAX, + PORTFOLIO_MEMBER_ROLES, PORTFOLIO_NAME_MAX, PORTFOLIO_PROFILE_SLOTS, + PORTFOLIO_VISIBILITIES, } from "@palacards/game"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; @@ -19,9 +21,14 @@ import { designCatalog, getPortfolio, getPortfolioBackground, + invitePortfolioMember, + joinPortfolio, likePortfolio, myPortfolios, + portfolioInvitations, profilePortfolios, + removePortfolioMember, + setPortfolioMemberRole, savePortfolioBackground, setPortfolioCards, setProfilePortfolios, @@ -49,6 +56,7 @@ export function portfolioRoutes(api: FastifyInstance, ctx: Ctx) { return communityPortfolios(ctx, req.user.id, q.sort, q.page); }); api.get("/portfolios/mine", auth, async (req) => myPortfolios(ctx, req.user.id)); + api.get("/portfolios/invitations", auth, async (req) => portfolioInvitations(ctx, req.user.id)); api.get("/portfolios/designs", auth, async (req) => designCatalog(ctx, req.user.id)); api.post("/portfolios/designs/:key/buy", limited(20), async (req) => { const { key } = parse(z.object({ key: z.string().min(1).max(40) }), req.params); @@ -82,6 +90,7 @@ export function portfolioRoutes(api: FastifyInstance, ctx: Ctx) { background: z.string().min(1).max(40).optional(), frame: z.string().min(1).max(40).optional(), accent: z.enum(PORTFOLIO_ACCENT_KEYS).optional(), + visibility: z.enum(PORTFOLIO_VISIBILITIES).optional(), }) .refine((v) => Object.keys(v).length > 0, "Rien à modifier"), req.body, @@ -107,6 +116,34 @@ export function portfolioRoutes(api: FastifyInstance, ctx: Ctx) { await deletePortfolio(ctx, req.user.id, parse(idParams, req.params).id); return { ok: true }; }); + // Portfolios partagés (suggestion n° 125) : invitation d'un ami, acceptation, départ ou retrait. + const memberParams = idParams.extend({ username: z.string().min(1).max(30) }); + api.post("/portfolios/:id/members", limited(30), async (req) => { + const { id } = parse(idParams, req.params); + const { username, role } = parse( + z.object({ username: z.string().min(1).max(30), role: z.enum(PORTFOLIO_MEMBER_ROLES).optional() }), + req.body, + ); + await invitePortfolioMember(ctx, req.user.id, id, (await findUserByName(ctx.db, username)).id, role); + return getPortfolio(ctx, req.user.id, id); + }); + // Droits d'un invité (contributeur ou éditeur), choisis par le propriétaire. + api.patch("/portfolios/:id/members/:username", limited(60), async (req) => { + const { id, username } = parse(memberParams, req.params); + const { role } = parse(z.object({ role: z.enum(PORTFOLIO_MEMBER_ROLES) }), req.body); + await setPortfolioMemberRole(ctx, req.user.id, id, (await findUserByName(ctx.db, username)).id, role); + return getPortfolio(ctx, req.user.id, id); + }); + api.delete("/portfolios/:id/members/:username", auth, async (req) => { + const { id, username } = parse(memberParams, req.params); + await removePortfolioMember(ctx, req.user.id, id, (await findUserByName(ctx.db, username)).id); + return { ok: true }; + }); + api.post("/portfolios/:id/join", limited(30), async (req) => { + const { id } = parse(idParams, req.params); + await joinPortfolio(ctx, req.user.id, id); + return getPortfolio(ctx, req.user.id, id); + }); api.post("/portfolios/:id/like", limited(60), async (req) => likePortfolio(ctx, req.user.id, parse(idParams, req.params).id, true), ); diff --git a/apps/api/src/services/notifications.ts b/apps/api/src/services/notifications.ts index bd938db..2035506 100644 --- a/apps/api/src/services/notifications.ts +++ b/apps/api/src/services/notifications.ts @@ -15,6 +15,8 @@ export type NotificationType = | "trade_expired" | "friend_request" | "friend_accepted" + | "portfolio_invite" + | "portfolio_joined" | "wishlist_listed" | "battle_challenge" | "battle_result" @@ -37,7 +39,7 @@ export type NotificationType = export const NOTIFICATION_GROUPS: Record = { market: ["outbid", "auction_won", "auction_sold", "auction_expired", "wishlist_listed"], trades: ["trade_received", "trade_accepted", "trade_declined", "trade_countered", "trade_expired"], - social: ["friend_request", "friend_accepted", "guild_objective"], + social: ["friend_request", "friend_accepted", "portfolio_invite", "portfolio_joined", "guild_objective"], battles: ["battle_challenge", "battle_result"], packs: ["packs_full", "gift"], achievements: ["achievement", "achievement_backfill", "title_won"], diff --git a/apps/api/src/services/portfolios.ts b/apps/api/src/services/portfolios.ts index cd03c0e..5d932e4 100644 --- a/apps/api/src/services/portfolios.ts +++ b/apps/api/src/services/portfolios.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, isNotNull, schema, sql } from "@palacards/db"; +import { and, asc, desc, eq, inArray, isNotNull, isNull, schema, sql } from "@palacards/db"; import { eligibleDesigns, normalizePortfolioCards, @@ -8,7 +8,12 @@ import { PORTFOLIO_IMAGE_BACKGROUND, PORTFOLIO_MAX, PORTFOLIO_PROFILE_SLOTS, + PORTFOLIO_SHARED_MAX_PLAYERS, + portfolioCardsChange, type PortfolioAccent, + type PortfolioMemberRole, + type PortfolioRights, + type PortfolioVisibility, } from "@palacards/game"; import { BANNER_IMAGE_MAX_BYTES, @@ -22,6 +27,7 @@ import { badRequest, conflict, forbidden, notFound } from "../errors.js"; import { sniffImage } from "./avatars.js"; import { BANNER_MAX_SIDE } from "./banners.js"; import { instanceColumns, toCardDTO } from "./cards.js"; +import { Effects } from "./notifications.js"; import { lockPlayer, movePw, pushWallet, type DbOrTx } from "./players.js"; type Tx = Parameters[0]>[0]; @@ -31,6 +37,7 @@ const pf = schema.portfolios; const pc = schema.portfolioCards; const pl = schema.portfolioLikes; const pb = schema.portfolioBackgrounds; +const pm = schema.portfolioMembers; const ci = schema.cardInstances; /** Cartes montrées dans un aperçu (liste, profil). */ @@ -43,17 +50,24 @@ const versionOf = (d: Date) => d.getTime().toString(36); // --------------------------------------------------------------------------- /** - * Cartes de ces portfolios, dans l'ordre, encore possédées par leur auteur (une carte qui change de main en sort à - * la transmission ; le filtre sur le propriétaire n'est qu'un garde-fou). Vues seulement pour l'auteur. + * La carte est à un participant du portfolio : son propriétaire ou un ami qui l'a rejoint. Une carte qui change de + * main en sort à la transmission, celles d'un ami qui part à son départ : ce filtre n'est qu'un garde-fou. */ +const heldByParticipant = sql`(${ci.ownerId} = ${pf.userId} or exists ( + select 1 from portfolio_members m + where m.portfolio_id = ${pf.id} and m.user_id = ${ci.ownerId} and m.joined_at is not null +))`; + +/** Cartes de ces portfolios, dans l'ordre, encore à un participant. Vues seulement pour celui à qui est la carte. */ async function cardsOf(db: DbOrTx, list: Portfolio[], viewerId: string, limit?: number) { const out = new Map(list.map((p) => [p.id, []])); if (!list.length) return out; const rows = await db - .select({ ...instanceColumns, portfolioId: pc.portfolioId, featured: pc.featured }) + .select({ ...instanceColumns, portfolioId: pc.portfolioId, featured: pc.featured, by: schema.user.username }) .from(pc) .innerJoin(pf, eq(pf.id, pc.portfolioId)) - .innerJoin(ci, and(eq(ci.id, pc.instanceId), eq(ci.ownerId, pf.userId))) + .innerJoin(ci, and(eq(ci.id, pc.instanceId), heldByParticipant)) + .innerJoin(schema.user, eq(schema.user.id, ci.ownerId)) .innerJoin(schema.cards, and(eq(schema.cards.season, ci.season), eq(schema.cards.id, ci.cardId))) .leftJoin(schema.wikiSummaries, eq(schema.wikiSummaries.pageId, ci.cardId)) .where( @@ -63,21 +77,54 @@ async function cardsOf(db: DbOrTx, list: Portfolio[], viewerId: string, limit?: ), ) .orderBy(asc(pc.portfolioId), asc(pc.pos)); - for (const { portfolioId, featured, ...r } of rows) - out.get(portfolioId)!.push({ ...toCardDTO(r, {}, viewerId), featured }); + for (const { portfolioId, featured, by, ...r } of rows) + out.get(portfolioId)!.push({ ...toCardDTO(r, {}, viewerId), featured, by: by ?? "" }); if (limit !== undefined) for (const [id, cards] of out) out.set(id, [...cards.filter((c) => c.featured), ...cards.filter((c) => !c.featured)].slice(0, limit)); return out; } -/** Résumés de ces portfolios pour `viewerId` (auteur, likes, aperçu ou toutes les cartes). */ +type MemberRow = { + portfolio_id: string; + user_id: string; + username: string; + display_name: string; + avatar: string | null; + joined_at: Date | string | null; + role: PortfolioMemberRole; + profile_slot: number | null; +}; + +/** Participants et invités de ces portfolios, dans l'ordre d'arrivée (les invitations en attente à la fin). */ +async function membersOf(db: DbOrTx, ids: number[]) { + if (!ids.length) return []; + return db.execute(sql` + select m.portfolio_id, m.user_id, u.username, coalesce(u.display_username, u.name) as display_name, p.avatar, + m.joined_at, m.role, m.profile_slot + from portfolio_members m join "user" u on u.id = m.user_id join players p on p.user_id = u.id + where m.portfolio_id in (${sql.join( + ids.map((id) => sql`${id}`), + sql`, `, + )}) + order by m.joined_at asc nulls last, m.invited_at asc + `); +} + +const toMemberDTO = (m: MemberRow) => ({ + username: m.username, + displayName: m.display_name, + avatar: m.avatar, + role: m.role, +}); + +/** Résumés de ces portfolios pour `viewerId` (auteur, participants, likes, aperçu ou toutes les cartes). */ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full: true): Promise; async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full?: false): Promise; async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = false) { if (!list.length) return []; const ids = list.map((p) => p.id); - const [owners, likes, mineLiked, counts, backgrounds, allCards] = await Promise.all([ + const [owners, likes, mineLiked, counts, backgrounds, allCards, members] = await Promise.all([ ctx.db.execute<{ id: string; username: string; display_name: string; avatar: string | null }>(sql` select u.id, u.username, coalesce(u.display_username, u.name) as display_name, p.avatar from "user" u join players p on p.user_id = u.id @@ -98,12 +145,13 @@ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = f ctx.db .select({ id: pc.portfolioId, n: sql`count(*)::int` }) .from(pc) - .innerJoin(ci, eq(ci.id, pc.instanceId)) - .innerJoin(pf, and(eq(pf.id, pc.portfolioId), eq(pf.userId, ci.ownerId))) + .innerJoin(pf, eq(pf.id, pc.portfolioId)) + .innerJoin(ci, and(eq(ci.id, pc.instanceId), heldByParticipant)) .where(inArray(pc.portfolioId, ids)) .groupBy(pc.portfolioId), ctx.db.select({ id: pb.portfolioId, updatedAt: pb.updatedAt }).from(pb).where(inArray(pb.portfolioId, ids)), cardsOf(ctx.db, list, viewerId, full ? undefined : PREVIEW_CARDS), + membersOf(ctx.db, ids), ]); const ownerBy = new Map(owners.map((o) => [o.id, o])); const likesBy = new Map(likes.map((l) => [l.id, l.n])); @@ -113,6 +161,10 @@ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = f return list.map((p) => { const o = ownerBy.get(p.userId); const cards = allCards.get(p.id) ?? []; + const here = members.filter((m) => Number(m.portfolio_id) === p.id); + const me = here.find((m) => m.user_id === viewerId); + const role = p.userId === viewerId ? "owner" : me ? (me.joined_at ? "member" : "invited") : null; + const rights = role === "owner" ? "owner" : me ? me.role : null; const summary: PortfolioSummaryDTO = { id: p.id, name: p.name, @@ -128,22 +180,32 @@ async function summaries(ctx: Ctx, list: Portfolio[], viewerId: string, full = f likes: likesBy.get(p.id) ?? 0, liked: liked.has(p.id), mine: p.userId === viewerId, - profileSlot: p.profileSlot, + // Place sur le profil du visiteur s'il participe, sinon sur celui du propriétaire. + profileSlot: role === "member" ? (me!.profile_slot ?? null) : p.profileSlot, preview: full ? [...cards.filter((c) => c.featured), ...cards.filter((c) => !c.featured)].slice(0, PREVIEW_CARDS) : cards, + visibility: p.visibility, + role, + rights, + members: here.filter((m) => m.joined_at).map(toMemberDTO), createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), }; - return full ? { ...summary, cards } : summary; + if (!full) return summary; + const invited = role === "owner" ? here.filter((m) => !m.joined_at).map(toMemberDTO) : []; + return { ...summary, cards, invited }; }); } -/** Portfolio non vide (une carte encore possédée) : visible des autres joueurs. */ -const nonEmpty = sql`exists ( +/** Portfolio public et non vide (une carte encore à un participant) : visible des autres joueurs. */ +const isPublic = sql`(${pf.visibility} = 'public' and exists ( select 1 from portfolio_cards x join card_instances i on i.id = x.instance_id - where x.portfolio_id = ${pf.id} and i.owner_id = ${pf.userId} -)`; + where x.portfolio_id = ${pf.id} and (i.owner_id = ${pf.userId} or exists ( + select 1 from portfolio_members m + where m.portfolio_id = ${pf.id} and m.user_id = i.owner_id and m.joined_at is not null + )) +))`; async function load(db: DbOrTx, id: number): Promise { const [p] = await db.select().from(pf).where(eq(pf.id, id)); @@ -151,6 +213,26 @@ async function load(db: DbOrTx, id: number): Promise { return p; } +/** Place d'un joueur dans un portfolio : propriétaire, participant, invité ou aucune. */ +async function roleIn(db: DbOrTx, p: Portfolio, userId: string): Promise { + return (await placeIn(db, p, userId)).role; +} + +/** Place et droits d'un joueur dans un portfolio. */ +async function placeIn( + db: DbOrTx, + p: Portfolio, + userId: string, +): Promise<{ role: PortfolioSummaryDTO["role"]; rights: PortfolioRights | null }> { + if (p.userId === userId) return { role: "owner", rights: "owner" }; + const [m] = await db + .select({ joinedAt: pm.joinedAt, role: pm.role }) + .from(pm) + .where(and(eq(pm.portfolioId, p.id), eq(pm.userId, userId))); + if (!m) return { role: null, rights: null }; + return { role: m.joinedAt ? "member" : "invited", rights: m.role }; +} + /** Son propre portfolio, verrouillé (modification). */ async function own(tx: Tx, userId: string, id: number): Promise { const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); @@ -159,31 +241,75 @@ async function own(tx: Tx, userId: string, id: number): Promise { return p; } -/** Page d'un portfolio : son auteur le voit toujours, les autres seulement s'il a une carte. */ +/** Portfolio dont on est propriétaire ou participant, verrouillé (ses cartes), avec ses droits. */ +async function editable(tx: Tx, userId: string, id: number) { + const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); + if (!p) throw notFound("Ce portfolio n'existe pas."); + const { role, rights } = await placeIn(tx, p, userId); + if ((role !== "owner" && role !== "member") || !rights) throw forbidden("Tu ne participes pas à ce portfolio."); + return { p, rights }; +} + +/** + * Page d'un portfolio : son propriétaire, ses participants et ses invités le voient toujours ; les autres + * seulement s'il est public et a une carte. + */ export async function getPortfolio(ctx: Ctx, viewerId: string, id: number): Promise { const p = await load(ctx.db, id); const [dto] = await summaries(ctx, [p], viewerId, true); - if (p.userId !== viewerId && !dto!.cards.length) throw notFound("Ce portfolio n'existe pas."); + if (!dto!.role && (p.visibility !== "public" || !dto!.cards.length)) throw notFound("Ce portfolio n'existe pas."); return dto!; } -/** Ses portfolios : ceux du profil d'abord (dans l'ordre), puis les plus récents. */ +/** Ses portfolios et ceux qu'on partage avec un ami : ceux du profil d'abord (dans l'ordre), puis les plus récents. */ export async function myPortfolios(ctx: Ctx, userId: string): Promise { const list = await ctx.db .select() .from(pf) - .where(eq(pf.userId, userId)) - .orderBy(sql`${pf.profileSlot} asc nulls last`, desc(pf.updatedAt)); + .where( + sql`(${pf.userId} = ${userId} or exists ( + select 1 from portfolio_members m where m.portfolio_id = ${pf.id} and m.user_id = ${userId} + and m.joined_at is not null + ))`, + ) + .orderBy( + sql`coalesce(case when ${pf.userId} = ${userId} then ${pf.profileSlot} end, ( + select m.profile_slot from portfolio_members m where m.portfolio_id = ${pf.id} and m.user_id = ${userId} + )) asc nulls last`, + desc(pf.updatedAt), + ); return summaries(ctx, list, userId); } -/** Portfolios du profil d'un joueur (3 au plus, dans l'ordre qu'il a choisi), non vides. */ +/** Invitations en attente dans les portfolios partagés d'amis, les plus récentes d'abord. */ +export async function portfolioInvitations(ctx: Ctx, userId: string): Promise { + const rows = await ctx.db + .select({ p: pf }) + .from(pm) + .innerJoin(pf, eq(pf.id, pm.portfolioId)) + .where(and(eq(pm.userId, userId), isNull(pm.joinedAt))) + .orderBy(desc(pm.invitedAt)); + return summaries( + ctx, + rows.map((r) => r.p), + userId, + ); +} + +/** + * Portfolios du profil d'un joueur (3 au plus, dans l'ordre qu'il a choisi), publics et non vides : les siens et les + * portfolios partagés d'amis qu'il y a épinglés. + */ export async function profilePortfolios(ctx: Ctx, viewerId: string, ownerId: string) { + const memberSlot = sql`(select m.profile_slot from portfolio_members m + where m.portfolio_id = ${pf.id} and m.user_id = ${ownerId} and m.joined_at is not null)`; const list = await ctx.db .select() .from(pf) - .where(and(eq(pf.userId, ownerId), isNotNull(pf.profileSlot), nonEmpty)) - .orderBy(asc(pf.profileSlot)); + .where( + and(sql`((${pf.userId} = ${ownerId} and ${pf.profileSlot} is not null) or ${memberSlot} is not null)`, isPublic), + ) + .orderBy(sql`coalesce(case when ${pf.userId} = ${ownerId} then ${pf.profileSlot} end, ${memberSlot})`); return summaries(ctx, list, viewerId); } @@ -195,7 +321,7 @@ export async function communityPortfolios(ctx: Ctx, viewerId: string, sort: "rec const list = await ctx.db .select() .from(pf) - .where(nonEmpty) + .where(isPublic) .orderBy(...(sort === "likes" ? [sql`${likes} desc`, desc(pf.createdAt)] : [desc(pf.createdAt)]), desc(pf.id)) .limit(COMMUNITY_PAGE + 1) .offset(page * COMMUNITY_PAGE); @@ -229,12 +355,22 @@ export async function createPortfolio(ctx: Ctx, userId: string, input: { name: s }); } -/** Nom, description et design ; un design pas encore à soi est refusé. */ +/** + * Nom, description, design et visibilité (propriétaire seul) ; un design pas encore à soi est refusé. Réservé aux + * participants, le portfolio quitte le profil. + */ export async function updatePortfolio( ctx: Ctx, userId: string, id: number, - input: { name?: string; description?: string; background?: string; frame?: string; accent?: PortfolioAccent }, + input: { + name?: string; + description?: string; + background?: string; + frame?: string; + accent?: PortfolioAccent; + visibility?: PortfolioVisibility; + }, ) { const have = await designsOf(ctx.db, userId); await ctx.db.transaction(async (tx) => { @@ -254,12 +390,22 @@ export async function updatePortfolio( } await tx .update(pf) - .set({ ...input, updatedAt: ctx.now() }) + .set({ + ...input, + ...(input.visibility === "members" ? { profileSlot: null } : {}), + updatedAt: ctx.now(), + }) .where(eq(pf.id, p.id)); + // Réservé aux participants : il quitte aussi le profil des amis qui l'avaient épinglé. + if (input.visibility === "members") await tx.update(pm).set({ profileSlot: null }).where(eq(pm.portfolioId, p.id)); }); } -/** Remplace les cartes (ordre et vedette) : des exemplaires de sa collection seulement. */ +/** + * Remplace les cartes (ordre et vedette), par le propriétaire ou un participant. Chacun n'ajoute que des exemplaires + * de sa collection ; un contributeur ne retire que les siens et ne touche ni à l'ordre ni à la vedette, un éditeur + * fait comme le propriétaire. Rien ne change de main. + */ export async function setPortfolioCards( ctx: Ctx, userId: string, @@ -269,20 +415,30 @@ export async function setPortfolioCards( const list = normalizePortfolioCards(cards); if (!list) throw badRequest("portfolio_cards", "Liste de cartes invalide (30 au plus, chacune une fois)."); await ctx.db.transaction(async (tx) => { - const p = await own(tx, userId, id); - const ids = list.map((c) => c.instanceId); - if (ids.length) { + const { p, rights } = await editable(tx, userId, id); + const before = await tx + .select({ instanceId: pc.instanceId, ownerId: ci.ownerId, featured: pc.featured }) + .from(pc) + .innerJoin(pf, eq(pf.id, pc.portfolioId)) + .innerJoin(ci, and(eq(ci.id, pc.instanceId), heldByParticipant)) + .where(eq(pc.portfolioId, p.id)) + .orderBy(asc(pc.pos)); + const change = portfolioCardsChange({ editorId: userId, rights, before, after: list }); + if (!change) throw forbidden("Tu ne peux retirer que tes propres cartes."); + if (change.added.length) { const owned = await tx .select({ id: ci.id }) .from(ci) - .where(and(inArray(ci.id, ids), eq(ci.ownerId, userId))); - if (owned.length !== ids.length) throw notFound("Certaines cartes ne sont plus dans ta collection."); + .where(and(inArray(ci.id, change.added), eq(ci.ownerId, userId))); + if (owned.length !== change.added.length) throw notFound("Certaines cartes ne sont plus dans ta collection."); } await tx.delete(pc).where(eq(pc.portfolioId, p.id)); - if (list.length) + if (change.cards.length) await tx .insert(pc) - .values(list.map((c, pos) => ({ portfolioId: p.id, instanceId: c.instanceId, pos, featured: c.featured }))); + .values( + change.cards.map((c, pos) => ({ portfolioId: p.id, instanceId: c.instanceId, pos, featured: c.featured })), + ); await tx.update(pf).set({ updatedAt: ctx.now() }).where(eq(pf.id, p.id)); }); } @@ -301,20 +457,187 @@ export async function setProfilePortfolios(ctx: Ctx, userId: string, ids: number throw badRequest("portfolio_profile", `Choisis jusqu'à ${PORTFOLIO_PROFILE_SLOTS} portfolios différents.`); await ctx.db.transaction(async (tx) => { await lockPlayer(tx, userId); - if (wanted.length) { - const mine = await tx - .select({ id: pf.id }) - .from(pf) - .where(and(inArray(pf.id, wanted), eq(pf.userId, userId))); - if (mine.length !== wanted.length) throw notFound("Portfolio introuvable."); - } - // Tout est libéré d'abord : l'index unique (joueur, place) ne voit jamais deux portfolios au même endroit. + // Les siens, ou un portfolio partagé d'ami qu'on a rejoint ; publics dans les deux cas. + const allowed = wanted.length + ? await tx + .select({ id: pf.id, ownerId: pf.userId }) + .from(pf) + .where( + and( + inArray(pf.id, wanted), + eq(pf.visibility, "public"), + sql`(${pf.userId} = ${userId} or exists ( + select 1 from portfolio_members m + where m.portfolio_id = ${pf.id} and m.user_id = ${userId} and m.joined_at is not null + ))`, + ), + ) + : []; + if (allowed.length !== wanted.length) throw notFound("Portfolio introuvable."); + const owned = new Set(allowed.filter((a) => a.ownerId === userId).map((a) => a.id)); + // Tout est libéré d'abord : les index uniques (joueur, place) ne voient jamais deux portfolios au même endroit. await tx.update(pf).set({ profileSlot: null }).where(eq(pf.userId, userId)); + await tx.update(pm).set({ profileSlot: null }).where(eq(pm.userId, userId)); for (const [i, id] of wanted.entries()) - await tx - .update(pf) - .set({ profileSlot: i + 1 }) - .where(eq(pf.id, id)); + if (owned.has(id)) + await tx + .update(pf) + .set({ profileSlot: i + 1 }) + .where(eq(pf.id, id)); + else + await tx + .update(pm) + .set({ profileSlot: i + 1 }) + .where(and(eq(pm.portfolioId, id), eq(pm.userId, userId))); + }); +} + +// --------------------------------------------------------------------------- +// Portfolios partagés (suggestion n° 125) : invitations d'amis, départ, retrait +// --------------------------------------------------------------------------- + +async function areFriends(db: DbOrTx, x: string, y: string) { + const [a, b] = x < y ? [x, y] : [y, x]; + const [row] = await db + .select({ status: schema.friendships.status }) + .from(schema.friendships) + .where(and(eq(schema.friendships.userA, a), eq(schema.friendships.userB, b))); + return row?.status === "accepted"; +} + +async function nameOf(db: DbOrTx, userId: string) { + const [u] = await db + .select({ name: sql`coalesce(${schema.user.displayUsername}, ${schema.user.name})` }) + .from(schema.user) + .where(eq(schema.user.id, userId)); + return u?.name ?? "?"; +} + +/** Le propriétaire invite un ami ; propriétaire et invitations compris, PORTFOLIO_SHARED_MAX_PLAYERS joueurs au plus. */ +export async function invitePortfolioMember( + ctx: Ctx, + userId: string, + id: number, + friendId: string, + role: PortfolioMemberRole = "contributor", +) { + if (friendId === userId) throw badRequest("self", "Tu participes déjà à ton portfolio."); + const fx = new Effects(); + await ctx.db.transaction(async (tx) => { + const p = await own(tx, userId, id); + if (!(await areFriends(tx, userId, friendId))) throw forbidden("Tu ne peux inviter que tes amis."); + const rows = await tx.select({ userId: pm.userId }).from(pm).where(eq(pm.portfolioId, p.id)); + if (rows.some((r) => r.userId === friendId)) throw conflict("portfolio_member", "Cet ami est déjà invité."); + if (rows.length + 1 >= PORTFOLIO_SHARED_MAX_PLAYERS) + throw conflict( + "portfolio_members_full", + `Un portfolio partagé réunit ${PORTFOLIO_SHARED_MAX_PLAYERS} joueurs au plus, toi compris.`, + ); + await tx.insert(pm).values({ portfolioId: p.id, userId: friendId, invitedAt: ctx.now(), role }); + await fx.notify(tx, friendId, "portfolio_invite", { + from: await nameOf(tx, userId), + portfolioId: p.id, + name: p.name, + }); + }); + await fx.flush(ctx); +} + +/** L'ami invité accepte : il peut dès lors y poser ses cartes. Il faut être encore amis. */ +export async function joinPortfolio(ctx: Ctx, userId: string, id: number) { + const fx = new Effects(); + await ctx.db.transaction(async (tx) => { + const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); + const [m] = p + ? await tx + .select() + .from(pm) + .where(and(eq(pm.portfolioId, id), eq(pm.userId, userId))) + : []; + if (!p || !m) throw notFound("Aucune invitation pour ce portfolio."); + if (m.joinedAt) throw conflict("portfolio_member", "Tu participes déjà à ce portfolio."); + if (!(await areFriends(tx, userId, p.userId))) + throw forbidden("Vous n'êtes plus amis : l'invitation ne tient plus."); + await tx + .update(pm) + .set({ joinedAt: ctx.now() }) + .where(and(eq(pm.portfolioId, id), eq(pm.userId, userId))); + // On n'aime pas un portfolio auquel on participe. + await tx.delete(pl).where(and(eq(pl.portfolioId, id), eq(pl.userId, userId))); + await fx.notify(tx, p.userId, "portfolio_joined", { + from: await nameOf(tx, userId), + portfolioId: id, + name: p.name, + }); + }); + await fx.flush(ctx); +} + +/** Sort un participant (ou une invitation) d'un portfolio : ses cartes le quittent et restent dans sa collection. */ +async function dropMember(tx: Tx, id: number, memberId: string, now: Date) { + const gone = await tx + .delete(pm) + .where(and(eq(pm.portfolioId, id), eq(pm.userId, memberId))) + .returning({ userId: pm.userId }); + if (!gone.length) return false; + await tx + .delete(pc) + .where( + and( + eq(pc.portfolioId, id), + inArray(pc.instanceId, tx.select({ id: ci.id }).from(ci).where(eq(ci.ownerId, memberId))), + ), + ); + await tx.update(pf).set({ updatedAt: now }).where(eq(pf.id, id)); + return true; +} + +/** + * Retire un participant ou une invitation : le propriétaire pour n'importe qui (révocation), chacun pour soi-même + * (départ, refus). + */ +export async function removePortfolioMember(ctx: Ctx, userId: string, id: number, memberId: string) { + await ctx.db.transaction(async (tx) => { + const [p] = await tx.select().from(pf).where(eq(pf.id, id)).for("update"); + if (!p) throw notFound("Ce portfolio n'existe pas."); + if (memberId !== userId && p.userId !== userId) throw forbidden("Ce portfolio n'est pas le tien."); + if (!(await dropMember(tx, id, memberId, ctx.now()))) throw notFound("Ce joueur ne participe pas à ce portfolio."); + }); +} + +/** Le propriétaire change les droits d'un participant ou d'une invitation ; ses cartes déjà posées restent. */ +export async function setPortfolioMemberRole( + ctx: Ctx, + userId: string, + id: number, + memberId: string, + role: PortfolioMemberRole, +) { + await ctx.db.transaction(async (tx) => { + const p = await own(tx, userId, id); + const done = await tx + .update(pm) + .set({ role }) + .where(and(eq(pm.portfolioId, p.id), eq(pm.userId, memberId))) + .returning({ userId: pm.userId }); + if (!done.length) throw notFound("Ce joueur ne participe pas à ce portfolio."); + }); +} + +/** Deux joueurs ne sont plus amis : chacun quitte les portfolios partagés de l'autre. */ +export async function leaveSharedPortfolios(ctx: Ctx, x: string, y: string) { + await ctx.db.transaction(async (tx) => { + for (const [ownerId, memberId] of [ + [x, y], + [y, x], + ] as const) { + const rows = await tx + .select({ id: pm.portfolioId }) + .from(pm) + .innerJoin(pf, eq(pf.id, pm.portfolioId)) + .where(and(eq(pf.userId, ownerId), eq(pm.userId, memberId))); + for (const r of rows) await dropMember(tx, r.id, memberId, ctx.now()); + } }); } @@ -324,12 +647,13 @@ export async function setProfilePortfolios(ctx: Ctx, userId: string, ids: number export async function likePortfolio(ctx: Ctx, userId: string, id: number, like: boolean) { const p = await load(ctx.db, id); - if (p.userId === userId) throw forbidden("Tu ne peux pas aimer ton propre portfolio."); + const role = await roleIn(ctx.db, p, userId); + if (role === "owner" || role === "member") throw forbidden("Tu ne peux pas aimer ton propre portfolio."); if (like) { const [visible] = await ctx.db .select({ id: pf.id }) .from(pf) - .where(and(eq(pf.id, id), nonEmpty)); + .where(and(eq(pf.id, id), isPublic)); if (!visible) throw notFound("Ce portfolio n'existe pas."); await ctx.db.insert(pl).values({ portfolioId: id, userId, createdAt: ctx.now() }).onConflictDoNothing(); } else { diff --git a/apps/api/src/services/privacy.ts b/apps/api/src/services/privacy.ts index 69989d3..ff5fa34 100644 --- a/apps/api/src/services/privacy.ts +++ b/apps/api/src/services/privacy.ts @@ -157,6 +157,8 @@ export async function exportData(ctx: Ctx, userId: string) { .from(s.playerPortfolioDesigns) .where(eq(s.playerPortfolioDesigns.userId, userId)), portfolioLikes: await db.select().from(s.portfolioLikes).where(eq(s.portfolioLikes.userId, userId)), + // Portfolios partagés d'amis : invitations reçues et participations. + portfolioMemberships: await db.select().from(s.portfolioMembers).where(eq(s.portfolioMembers.userId, userId)), // Admin : cartes ajoutées ou retirées à la main d'un filtre d'images. imageFilterChoices: await db .select() diff --git a/apps/api/src/services/social.ts b/apps/api/src/services/social.ts index dea1ebb..584b8f7 100644 --- a/apps/api/src/services/social.ts +++ b/apps/api/src/services/social.ts @@ -3,6 +3,7 @@ import { dmChannel, guildChannel, type Rarity } from "@palacards/game"; import type { Ctx } from "../context.js"; import { badRequest, conflict, forbidden, notFound } from "../errors.js"; import { Effects } from "./notifications.js"; +import { leaveSharedPortfolios } from "./portfolios.js"; import { emit } from "./progression.js"; import { findUserByName, statusNoteOf } from "./profiles.js"; @@ -86,10 +87,11 @@ export async function acceptFriend(ctx: Ctx, userId: string, otherId: string) { } } -/** Refuse, annule une demande ou retire un ami. */ +/** Refuse, annule une demande ou retire un ami (chacun quitte alors les portfolios partagés de l'autre). */ export async function removeFriend(ctx: Ctx, userId: string, otherId: string) { const key = pair(userId, otherId); await ctx.db.delete(f).where(and(eq(f.userA, key.userA), eq(f.userB, key.userB))); + await leaveSharedPortfolios(ctx, userId, otherId); } export async function listFriends(ctx: Ctx, userId: string) { diff --git a/apps/api/test/portfolios.test.ts b/apps/api/test/portfolios.test.ts index 87aab9c..4cd0848 100644 --- a/apps/api/test/portfolios.test.ts +++ b/apps/api/test/portfolios.test.ts @@ -1,5 +1,5 @@ import { eq, schema, sql } from "@palacards/db"; -import { ECONOMY, PORTFOLIO_MAX, PORTFOLIO_PRICES } from "@palacards/game"; +import { ECONOMY, PORTFOLIO_MAX, PORTFOLIO_PRICES, PORTFOLIO_SHARED_MAX_PLAYERS } from "@palacards/game"; import { afterAll, describe, expect, it } from "vitest"; import { makeApp, signUp, signUpAdmin, type Client } from "./helpers.js"; @@ -191,3 +191,219 @@ describe("portfolios (suggestions n° 3 et 116)", () => { expect(exported.portfolioCards).toEqual([expect.objectContaining({ portfolioId: id, instanceId: card })]); }); }); + +describe("portfolios partagés (suggestion n° 125)", () => { + async function befriend(a: Client, b: Client) { + await a.post("/friends", { username: b.username }); + await b.post("/friends", { username: a.username }); + } + const ids = (body: { cards: { instanceId: number }[] }) => body.cards.map((c) => c.instanceId); + + it("invite un ami, qui y pose ses propres cartes sans qu'aucune change de main", async () => { + const owner = await signUp(app); + const friend = await signUp(app); + const stranger = await signUp(app); + await befriend(owner, friend); + const [o1] = await withCards(owner); + const [f1, f2] = await withCards(friend); + const id = await create(owner, "À deux"); + await owner.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }] }); + + // Seuls les amis peuvent être invités ; l'invité est prévenu et voit l'invitation. + expect((await owner.post(`/portfolios/${id}/members`, { username: stranger.username })).status).toBe(403); + const invited = await owner.post(`/portfolios/${id}/members`, { username: friend.username }); + expect(invited.status).toBe(200); + expect(invited.body.invited.map((m: { username: string }) => m.username)).toEqual([friend.username]); + expect((await owner.post(`/portfolios/${id}/members`, { username: friend.username })).status).toBe(409); + // L'invitation figure parmi les notifications (un succès débloqué peut arriver juste après). + const notes = (await friend.get("/notifications")).body.items as { type: string }[]; + expect(notes.map((n) => n.type)).toContain("portfolio_invite"); + const invitations = await friend.get("/portfolios/invitations"); + expect(invitations.body.map((p: { id: number; role: string }) => [p.id, p.role])).toEqual([[id, "invited"]]); + + // Pas encore participant : il ne peut rien y poser. + expect((await friend.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }] })).status).toBe(403); + const joined = await friend.post(`/portfolios/${id}/join`); + expect(joined.body.role).toBe("member"); + expect(joined.body.invited).toEqual([]); + expect((await owner.get("/notifications")).body.items[0].type).toBe("portfolio_joined"); + expect((await friend.get("/portfolios/mine")).body.map((p: { id: number }) => p.id)).toContain(id); + + // L'ami (contributeur par défaut) ajoute ses cartes, à la fin et hors vedette ; celle d'un autre est refusée, + // comme le retrait d'une carte qui n'est pas à lui. + expect(joined.body.rights).toBe("contributor"); + const added = await friend.put(`/portfolios/${id}/cards`, { + cards: [{ instanceId: f1 }, { instanceId: o1 }, { instanceId: f2, featured: true }], + }); + expect(added.status).toBe(200); + expect(added.body.cards.map((c: { by: string }) => c.by)).toEqual([ + owner.username, + friend.username, + friend.username, + ]); + expect(added.body.cards.some((c: { featured: boolean }) => c.featured)).toBe(false); + const [s1] = await withCards(stranger); + expect( + (await friend.put(`/portfolios/${id}/cards`, { cards: [...added.body.cards, { instanceId: s1 }] })).status, + ).toBe(404); + expect((await friend.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: f1 }] })).status).toBe(403); + // Il ne touche ni au nom ni au design, et n'aime pas un portfolio auquel il participe. + expect((await friend.patch(`/portfolios/${id}`, { name: "Volé" })).status).toBe(403); + expect((await friend.post(`/portfolios/${id}/like`)).status).toBe(403); + const [row] = await ctx.db.select().from(schema.cardInstances).where(eq(schema.cardInstances.id, f1!)); + expect(row!.ownerId).toBe(friend.userId); + + // Le propriétaire peut retirer la carte de son ami ; l'ami retire la sienne. + const trimmed = await owner.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }, { instanceId: f2 }] }); + expect(ids(trimmed.body)).toEqual([o1, f2]); + + // Révocation : l'ami perd l'accès et ses cartes quittent le portfolio (elles restent à lui). + expect((await owner.del(`/portfolios/${id}/members/${friend.username}`)).status).toBe(200); + expect(ids((await owner.get(`/portfolios/${id}`)).body)).toEqual([o1]); + expect((await friend.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1 }] })).status).toBe(403); + expect((await friend.get("/portfolios/mine")).body.map((p: { id: number }) => p.id)).not.toContain(id); + }); + + it("limite le nombre de joueurs, laisse partir un participant et retire les anciens amis", async () => { + const owner = await signUp(app); + const id = await create(owner); + const friends: Client[] = []; + for (let i = 0; i < PORTFOLIO_SHARED_MAX_PLAYERS; i++) { + const f = await signUp(app); + await befriend(owner, f); + friends.push(f); + } + for (const f of friends.slice(0, PORTFOLIO_SHARED_MAX_PLAYERS - 1)) + expect((await owner.post(`/portfolios/${id}/members`, { username: f.username })).status).toBe(200); + const full = await owner.post(`/portfolios/${id}/members`, { username: friends.at(-1)!.username }); + expect(full.body.error).toBe("portfolio_members_full"); + + // Un invité refuse (ou un participant part) : il se retire lui-même ; il ne retire pas les autres. + const [a, b, c] = friends; + expect((await a!.del(`/portfolios/${id}/members/${a!.username}`)).status).toBe(200); + expect((await b!.del(`/portfolios/${id}/members/${c!.username}`)).status).toBe(403); + await b!.post(`/portfolios/${id}/join`); + const [card] = await withCards(b!); + await b!.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: card }] }); + expect((await owner.get(`/portfolios/${id}`)).body.cardCount).toBe(1); + + // Plus amis : il quitte le portfolio, avec ses cartes. + expect((await owner.del(`/friends/${b!.userId}`)).status).toBe(200); + const after = await owner.get(`/portfolios/${id}`); + expect(after.body.members).toEqual([]); + expect(after.body.cardCount).toBe(0); + }); + + it("donne des droits par invité : le contributeur ajoute, l'éditeur range et retire comme le propriétaire", async () => { + const owner = await signUp(app); + const editor = await signUp(app); + const helper = await signUp(app); + await befriend(owner, editor); + await befriend(owner, helper); + const [o1, o2] = await withCards(owner); + const [e1] = await withCards(editor); + const [h1] = await withCards(helper); + const id = await create(owner, "À trois"); + await owner.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: o1, featured: true }, { instanceId: o2 }] }); + const invited = await owner.post(`/portfolios/${id}/members`, { username: editor.username, role: "editor" }); + expect(invited.body.invited.map((m: { role: string }) => m.role)).toEqual(["editor"]); + await owner.post(`/portfolios/${id}/members`, { username: helper.username }); + await editor.post(`/portfolios/${id}/join`); + await helper.post(`/portfolios/${id}/join`); + + // Seul le propriétaire change les droits. + expect((await helper.patch(`/portfolios/${id}/members/${helper.username}`, { role: "editor" })).status).toBe(403); + expect((await owner.patch(`/portfolios/${id}/members/${helper.username}`, { role: "boss" })).status).toBe(400); + + // Le contributeur ajoute sa carte ; son nouvel ordre et sa vedette sont ignorés. + const mixed = await helper.put(`/portfolios/${id}/cards`, { + cards: [{ instanceId: h1, featured: true }, { instanceId: o2 }, { instanceId: o1 }], + }); + expect(ids(mixed.body)).toEqual([o1, o2, h1]); + expect(mixed.body.cards.map((c: { featured: boolean }) => c.featured)).toEqual([true, false, false]); + + // L'éditeur range tout, change la vedette et retire la carte du propriétaire. + const arranged = await editor.put(`/portfolios/${id}/cards`, { + cards: [{ instanceId: h1 }, { instanceId: e1, featured: true }, { instanceId: o1 }], + }); + expect(arranged.status).toBe(200); + expect(ids(arranged.body)).toEqual([h1, e1, o1]); + expect(arranged.body.rights).toBe("editor"); + // Mais ni le titre, ni le partage. + expect((await editor.patch(`/portfolios/${id}`, { name: "À moi" })).status).toBe(403); + expect((await editor.del(`/portfolios/${id}/members/${helper.username}`)).status).toBe(403); + + // Rétrogradé en contributeur, il ne retire plus que ses cartes ; elles restent dans le portfolio. + const demoted = await owner.patch(`/portfolios/${id}/members/${editor.username}`, { role: "contributor" }); + expect(demoted.body.members.find((m: { username: string }) => m.username === editor.username).role).toBe( + "contributor", + ); + expect((await editor.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: e1 }] })).status).toBe(403); + expect(ids((await owner.get(`/portfolios/${id}`)).body)).toEqual([h1, e1, o1]); + }); + + it("laisse un participant épingler le portfolio partagé sur son propre profil", async () => { + const owner = await signUp(app); + const friend = await signUp(app); + const visitor = await signUp(app); + await befriend(owner, friend); + const [o1] = await withCards(owner); + const [f1] = await withCards(friend); + const shared = await create(owner, "Ensemble"); + const solo = await create(friend, "À moi"); + await owner.put(`/portfolios/${shared}/cards`, { cards: [{ instanceId: o1 }] }); + await friend.put(`/portfolios/${solo}/cards`, { cards: [{ instanceId: f1 }] }); + await owner.put("/portfolios/profile", { ids: [shared] }); + + // Une invitation pas encore acceptée ne suffit pas. + await owner.post(`/portfolios/${shared}/members`, { username: friend.username }); + expect((await friend.put("/portfolios/profile", { ids: [shared] })).status).toBe(404); + await friend.post(`/portfolios/${shared}/join`); + + expect((await friend.put("/portfolios/profile", { ids: [shared, solo] })).status).toBe(200); + const onProfile = await visitor.get(`/players/${friend.username}/portfolios`); + expect(onProfile.body.map((p: { id: number }) => p.id)).toEqual([shared, solo]); + // Sa place ne touche pas celle du propriétaire. + const mine = await friend.get("/portfolios/mine"); + expect(mine.body.find((p: { id: number }) => p.id === shared).profileSlot).toBe(1); + expect((await owner.get("/portfolios/mine")).body.find((p: { id: number }) => p.id === shared).profileSlot).toBe(1); + await friend.put("/portfolios/profile", { ids: [solo, shared] }); + expect((await visitor.get(`/players/${friend.username}/portfolios`)).body.map((p: { id: number }) => p.id)).toEqual( + [solo, shared], + ); + + // Réservé aux participants : il quitte les deux profils. + await owner.patch(`/portfolios/${shared}`, { visibility: "members" }); + expect((await visitor.get(`/players/${friend.username}/portfolios`)).body.map((p: { id: number }) => p.id)).toEqual( + [solo], + ); + expect((await friend.get("/portfolios/mine")).body.find((p: { id: number }) => p.id === shared).profileSlot).toBe( + null, + ); + }); + + it("cache aux autres un portfolio réservé à ses participants", async () => { + const owner = await signUp(app); + const friend = await signUp(app); + const visitor = await signUp(app); + await befriend(owner, friend); + const [card] = await withCards(owner); + const id = await create(owner, "Entre nous"); + await owner.put(`/portfolios/${id}/cards`, { cards: [{ instanceId: card }] }); + await owner.put("/portfolios/profile", { ids: [id] }); + await owner.post(`/portfolios/${id}/members`, { username: friend.username }); + expect((await visitor.get(`/portfolios/${id}`)).status).toBe(200); + + const hidden = await owner.patch(`/portfolios/${id}`, { visibility: "members" }); + expect(hidden.body.visibility).toBe("members"); + expect(hidden.body.profileSlot).toBeNull(); + expect((await visitor.get(`/portfolios/${id}`)).status).toBe(404); + expect((await visitor.post(`/portfolios/${id}/like`)).status).toBe(404); + expect((await visitor.get(`/players/${owner.username}/portfolios`)).body).toEqual([]); + const community = await visitor.get("/portfolios"); + expect(community.body.items.map((p: { id: number }) => p.id)).not.toContain(id); + expect((await owner.put("/portfolios/profile", { ids: [id] })).status).toBe(404); + // L'invité le voit, pour décider. + expect((await friend.get(`/portfolios/${id}`)).status).toBe(200); + }); +}); diff --git a/apps/e2e/tests/17-portfolios-partages.spec.ts b/apps/e2e/tests/17-portfolios-partages.spec.ts new file mode 100644 index 0000000..a1ea417 --- /dev/null +++ b/apps/e2e/tests/17-portfolios-partages.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; +import { apiCall, instantPacks, newPlayer } from "./helpers"; + +interface PortfolioDTO { + cards: { instanceId: number; by: string }[]; + members: { username: string }[]; +} + +test("inviter un ami dans un portfolio, qui y ajoute ses cartes, puis lui retirer l'accès (suggestion n° 125)", async ({ + browser, +}) => { + const owner = await newPlayer(browser, "hote"); + const friend = await newPlayer(browser, "invite"); + await apiCall(owner.page, "POST", "/friends", { username: friend.name }); + await apiCall(friend.page, "POST", "/friends", { username: owner.name }); + await instantPacks(friend.page); + await apiCall(friend.page, "POST", "/packs/open"); + const { id } = await apiCall<{ id: number }>(owner.page, "POST", "/portfolios", { name: "À plusieurs mains" }); + + // Le propriétaire invite son ami depuis l'éditeur. + const { page } = owner; + await page.goto(`portfolios/${id}/modifier`); + const share = page.getByRole("region", { name: /^Partage/ }); + await share.getByLabel("Inviter un ami").selectOption(friend.name); + await share.getByRole("button", { name: "Inviter" }).click(); + await expect(share.getByText("Invitation envoyée", { exact: true })).toBeVisible(); + await expect(share.getByRole("heading", { name: /2 \/ 10/ })).toBeVisible(); + // Contributeur à l'invitation ; le propriétaire le passe éditeur. + const rights = share.getByRole("radiogroup", { name: `Droits de ${friend.name}` }); + await expect(rights.getByRole("radio", { name: "Contributeur" })).toHaveAttribute("aria-checked", "true"); + await rights.getByRole("radio", { name: "Éditeur" }).click(); + await expect(rights.getByRole("radio", { name: "Éditeur" })).toHaveAttribute("aria-checked", "true"); + + // L'ami accepte depuis la page Portfolios et ajoute deux de ses cartes. + const f = friend.page; + await f.goto("portfolios"); + const invites = f.getByRole("region", { name: "Invitations" }); + await expect(invites.getByText("À plusieurs mains")).toBeVisible(); + await invites.getByRole("button", { name: "Rejoindre" }).click(); + await expect(invites).toHaveCount(0); + await f.getByRole("link", { name: "Mes cartes" }).click(); + await expect(f).toHaveURL(new RegExp(`/portfolios/${id}/modifier$`)); + await expect(f.getByRole("heading", { level: 1, name: "Tes cartes dans ce portfolio" })).toBeVisible(); + // Ni titre ni design pour lui. + await expect(f.getByRole("radio", { name: "Azur" })).toHaveCount(0); + await f.getByRole("button", { name: /Ajouter des cartes/ }).click(); + const picker = f.getByRole("dialog", { name: "Ajouter des cartes" }); + const free = picker.locator(".pc-pick:not([disabled]):not([aria-pressed='true'])"); + for (let i = 0; i < 2; i++) await free.first().click(); + await picker.getByRole("button", { name: "Ajouter (2)" }).click(); + await expect(f.getByRole("heading", { name: /Cartes\s*2 \/ 30/ })).toBeVisible(); + // Éditeur : il range l'ordre et la vedette. + await expect(f.getByRole("button", { name: /^Mettre .+ en vedette$/ })).toHaveCount(2); + const shared = await apiCall(f, "GET", `/portfolios/${id}`); + expect(shared.cards.map((c) => c.by)).toEqual([friend.name, friend.name]); + + // La page publique nomme les deux participants. + await page.goto(`portfolios/${id}`); + await expect(page.getByRole("link", { name: friend.name })).toBeVisible(); + await expect(page.getByRole("list", { name: "Cartes de À plusieurs mains" }).getByRole("listitem")).toHaveCount(2); + + // Le propriétaire retire l'accès : les cartes de l'ami quittent le portfolio et restent à lui. + await page.goto(`portfolios/${id}/modifier`); + await share.getByRole("button", { name: `Retirer ${friend.name} du portfolio` }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Retirer" }).click(); + await expect(page.getByRole("heading", { name: /Cartes\s*0 \/ 30/ })).toBeVisible(); + const after = await apiCall(page, "GET", `/portfolios/${id}`); + expect(after.members).toEqual([]); + const mine = await apiCall<{ items: unknown[] }>(f, "GET", "/collection?limit=60"); + expect(mine.items.length).toBeGreaterThanOrEqual(2); +}); diff --git a/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx b/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx index 6573226..adb3b2b 100644 --- a/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx +++ b/apps/web/src/app/(game)/portfolios/[id]/modifier/page.tsx @@ -1,8 +1,37 @@ "use client"; -import { PORTFOLIO_ACCENTS, PORTFOLIO_CARDS_MAX, PORTFOLIO_DESCRIPTION_MAX, PORTFOLIO_NAME_MAX } from "@palacards/game"; -import type { CardDTO, Page, PortfolioCardDTO, PortfolioDesignItemDTO, PortfolioDTO } from "@palacards/shared"; -import { ChevronLeft, ChevronRight, GripVertical, ImageUp, Lock, Plus, Star, Trash2, X } from "lucide-react"; +import { + arrangesPortfolio, + PORTFOLIO_ACCENTS, + PORTFOLIO_CARDS_MAX, + PORTFOLIO_DESCRIPTION_MAX, + PORTFOLIO_NAME_MAX, + PORTFOLIO_MEMBER_ROLE_LABELS, + PORTFOLIO_MEMBER_ROLES, + PORTFOLIO_SHARED_MAX_PLAYERS, + type PortfolioMemberRole, +} from "@palacards/game"; +import type { + CardDTO, + Page, + PortfolioCardDTO, + PortfolioDesignItemDTO, + PortfolioDTO, + PortfolioMemberDTO, +} from "@palacards/shared"; +import { + ChevronLeft, + ChevronRight, + GripVertical, + ImageUp, + Lock, + LogOut, + Plus, + Star, + Trash2, + UserPlus, + X, +} from "lucide-react"; import { motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -16,7 +45,8 @@ import { useCollectionFilters, type CollectionFilterOptions, } from "@/components/CollectionFilters"; -import { portfolioBackgroundSrc, PortfolioShell } from "@/components/Portfolio"; +import { Avatar } from "@/components/Avatar"; +import { participates, portfolioBackgroundSrc, PortfolioShell } from "@/components/Portfolio"; import { CardSkeletons, ConfirmDialog, Empty, ErrorBox, LoadMore } from "@/components/ui"; import { api, ApiError } from "@/lib/api"; import { preparePortfolioBackground } from "@/lib/avatar-image"; @@ -30,8 +60,14 @@ const errorText = (err: unknown, fallback: string) => (err instanceof ApiError ? // Cartes : ordre (poignée, flèches), vedette, retrait, ajout // --------------------------------------------------------------------------- -function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator }) { +function CardsEditor({ p, me, mutate }: { p: PortfolioDTO; me: string; mutate: KeyedMutator }) { const [order, setOrder] = useState(p.cards); + // Portfolio partagé : un contributeur ajoute et retire ses cartes ; le propriétaire et les éditeurs rangent tout. + const shared = p.members.length > 0; + const arranges = arrangesPortfolio(p.rights); + const canRemove = (c: PortfolioCardDTO) => arranges || c.by === me; + const names = new Map([p.owner, ...p.members].map((m) => [m.username, m.displayName])); + const byLabel = (c: PortfolioCardDTO) => (c.by === me ? "À toi" : `À ${names.get(c.by) ?? c.by}`); const [synced, setSynced] = useState(p.cards); const [picking, setPicking] = useState(false); const [announce, setAnnounce] = useState(""); @@ -108,7 +144,7 @@ function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator !known.has(c.instanceId)) .slice(0, PORTFOLIO_CARDS_MAX - order.length) - .map((c) => ({ ...c, featured: false })); + .map((c) => ({ ...c, featured: false, by: me })); if (!fresh.length) return; setInstant(false); void save([...order, ...fresh], order); @@ -126,7 +162,11 @@ function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator -

Poignée ou flèches pour l’ordre, étoile pour la vedette.

+

+ {arranges + ? "Poignée ou flèches pour l’ordre, étoile pour la vedette." + : `Tes cartes s’ajoutent à la fin ; ${p.owner.displayName} range l’ordre et la vedette.`} +

    {order.map((card, i) => { @@ -147,70 +187,100 @@ function CardsEditor({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator - - -
    - - - - + title={card.featured ? "En vedette" : "Mettre en vedette"} + onClick={() => feature(card)} + > + + + ) : ( + card.featured && ( + + + En vedette + + ) + )}
    + {!arranges ? ( +
    + {byLabel(card)} + {canRemove(card) && ( + + )} +
    + ) : ( +
    + + + + {canRemove(card) && ( + + )} +
    + )} + {shared && arranges &&

    {byLabel(card)}

    } ); })} @@ -681,6 +751,256 @@ function DesignPanel({ p, mutate }: { p: PortfolioDTO; mutate: KeyedMutator }) { + const friends = useSWR<{ friends: Friend[] }>("/friends"); + const [picked, setPicked] = useState(""); + const [busy, setBusy] = useState(false); + const [removing, setRemoving] = useState(null); + const taken = new Set([...p.members, ...p.invited].map((m) => m.username)); + const candidates = (friends.data?.friends ?? []).filter((f) => !taken.has(f.username)); + const players = 1 + p.members.length + p.invited.length; + const full = players >= PORTFOLIO_SHARED_MAX_PLAYERS; + const target = picked || candidates[0]?.username || ""; + + async function run(fn: () => Promise, done?: string) { + setBusy(true); + try { + await fn(); + await mutate(); + if (done) toast.success(done); + } catch (err) { + toast.error(errorText(err, "Modification non enregistrée.")); + } finally { + setBusy(false); + } + } + + function invite(e: FormEvent) { + e.preventDefault(); + const friend = candidates.find((f) => f.username === target); + if (!friend) return; + setPicked(""); + void run( + () => api(`/portfolios/${p.id}/members`, { body: { username: friend.username } }), + `Invitation envoyée à ${friend.displayName}.`, + ); + } + + function setRole(m: PortfolioMemberDTO, role: PortfolioMemberRole) { + if (m.role === role) return; + void run( + () => api(`/portfolios/${p.id}/members/${encodeURIComponent(m.username)}`, { method: "PATCH", body: { role } }), + `${m.displayName} : ${PORTFOLIO_MEMBER_ROLE_LABELS[role].toLowerCase()}.`, + ); + } + + function revoke(m: PortfolioMemberDTO) { + setRemoving(null); + void run( + () => api(`/portfolios/${p.id}/members/${encodeURIComponent(m.username)}`, { method: "DELETE" }), + `${m.displayName} n’a plus accès au portfolio.`, + ); + } + + const person = (m: PortfolioMemberDTO, status: string) => ( +
  1. + + + {m.displayName} + {status} + + {PORTFOLIO_MEMBER_ROLES.map((r) => ( + + ))} + + + +
  2. + ); + + return ( +
    +

    + Partage + + {players} / {PORTFOLIO_SHARED_MAX_PLAYERS} + +

    +

    + Tes amis invités y ajoutent leurs propres cartes, qui restent à eux. Un contributeur ajoute et retire les + siennes ; un éditeur range aussi l’ordre, la vedette et peut retirer toutes les cartes. Toi seul choisis le + titre, le design et le partage. +

    + {players > 1 && ( +
      + {p.members.map((m) => person(m, "Participe"))} + {p.invited.map((m) => person(m, "Invitation envoyée"))} +
    + )} + {full ? ( +

    + Le portfolio réunit déjà {PORTFOLIO_SHARED_MAX_PLAYERS} joueurs, toi compris. +

    + ) : friends.error ? ( + friends.mutate()} /> + ) : !friends.data ? ( +
    + ) : candidates.length ? ( +
    + +
    + + +
    +
    + ) : ( +

    + {friends.data.friends.length ? "Tous tes amis sont déjà invités." : "Tu n’as pas encore d’ami à inviter."}{" "} + + Ajouter des amis + +

    + )} + +
    + Qui voit ce portfolio + {VISIBILITIES.map((v) => ( + + ))} +
    + + removing && revoke(removing)} + onClose={() => setRemoving(null)} + > + Ses cartes quittent le portfolio et restent dans sa collection. Tu pourras l’inviter de nouveau. + +
    + ); +} + +/** Ce qu'un ami participant voit à la place du titre et du design : qui compose le portfolio, et le départ. */ +function MemberPanel({ p, me }: { p: PortfolioDTO; me: string }) { + const router = useRouter(); + const [leaving, setLeaving] = useState(false); + + async function leave() { + setLeaving(false); + try { + await api(`/portfolios/${p.id}/members/${encodeURIComponent(me)}`, { method: "DELETE" }); + toast.success("Tu as quitté le portfolio."); + router.push("/portfolios"); + } catch (err) { + toast.error(errorText(err, "Départ impossible.")); + } + } + + return ( +
    +

    + Partage +

    +

    + {p.rights === "editor" + ? `Portfolio de ${p.owner.displayName}, dont tu es éditeur : ajoute tes cartes, range l’ordre et la vedette, retire celles de tous.` + : `Portfolio de ${p.owner.displayName}, dont tu es contributeur : ajoute et retire tes propres cartes.`}{" "} + Le titre et le design restent à son choix. + {p.visibility === "public" && " Tu peux l’épingler sur ton profil, comme tes propres portfolios."} +

    +
      + {[{ ...p.owner, role: null }, ...p.members].map((m) => ( +
    • + + {m.username === me ? "Toi" : m.displayName} + {m.role ? PORTFOLIO_MEMBER_ROLE_LABELS[m.role] : "Propriétaire"} +
    • + ))} +
    + + setLeaving(false)} + > + Tes cartes en sortent et restent dans ta collection. Il faudra une nouvelle invitation pour revenir. + +
    + ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -689,6 +1009,7 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st const { id } = use(params); const router = useRouter(); const { data: p, error, mutate } = useSWR(`/portfolios/${encodeURIComponent(id)}`); + const { me } = useMe(); const [deleting, setDeleting] = useState(false); async function destroy() { @@ -703,10 +1024,10 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st } if (error) return mutate()} />; - if (!p) return
    ; - if (!p.mine) + if (!p || !me) return
    ; + if (!participates(p)) return ( - + Voir sa page @@ -720,22 +1041,24 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st Portfolios
    - {/* Sur mobile, le design vient après les cartes : raccourci. */} + {/* Sur mobile, le design et le partage viennent après les cartes : raccourci. */} - Design + {p.mine ? "Design et partage" : "Partage"} - {p.cardCount > 0 && ( + {(p.cardCount > 0 || !p.mine) && ( Voir la page )} - + {p.mine && ( + + )}
    -

    Modifier le portfolio

    +

    {p.mine ? "Modifier le portfolio" : "Tes cartes dans ce portfolio"}

    Chaque changement est enregistré aussitôt. Une carte vendue, échangée, recyclée, fusionnée ou upgradée quitte le portfolio. @@ -749,11 +1072,18 @@ export default function EditPortfolioPage({ params }: { params: Promise<{ id: st {p.description &&

    {p.description}

    } - +
    diff --git a/apps/web/src/app/(game)/portfolios/[id]/page.tsx b/apps/web/src/app/(game)/portfolios/[id]/page.tsx index 4ff1dbd..97d15d1 100644 --- a/apps/web/src/app/(game)/portfolios/[id]/page.tsx +++ b/apps/web/src/app/(game)/portfolios/[id]/page.tsx @@ -3,14 +3,18 @@ import type { PortfolioDTO } from "@palacards/shared"; import { ChevronLeft } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { use } from "react"; import useSWR from "swr"; -import { PortfolioActions, PortfolioView } from "@/components/Portfolio"; +import { InvitationAnswer, PortfolioActions, PortfolioView } from "@/components/Portfolio"; import { ErrorBox } from "@/components/ui"; +import { useMe } from "@/lib/game"; /** Page publique d'un portfolio (lien partageable). */ export default function PortfolioPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); + const { me } = useMe(); + const router = useRouter(); const { data: p, error, mutate } = useSWR(`/portfolios/${encodeURIComponent(id)}`); return ( @@ -21,6 +25,19 @@ export default function PortfolioPage({ params }: { params: Promise<{ id: string {p && }
    + {p?.role === "invited" && me && ( +
    +

    + {p.owner.displayName} t’invite à composer ce portfolio : tu pourras y ajouter tes propres + cartes, qui resteront dans ta collection. +

    + (joined ? void mutate() : router.push("/portfolios"))} + /> +
    + )} {error ? ( mutate()} /> ) : !p ? ( diff --git a/apps/web/src/app/(game)/portfolios/page.tsx b/apps/web/src/app/(game)/portfolios/page.tsx index cb9522f..334b90a 100644 --- a/apps/web/src/app/(game)/portfolios/page.tsx +++ b/apps/web/src/app/(game)/portfolios/page.tsx @@ -1,17 +1,25 @@ "use client"; -import { PORTFOLIO_CARDS_MAX, PORTFOLIO_DESCRIPTION_MAX, PORTFOLIO_MAX, PORTFOLIO_NAME_MAX } from "@palacards/game"; +import { + PORTFOLIO_CARDS_MAX, + PORTFOLIO_DESCRIPTION_MAX, + PORTFOLIO_MAX, + PORTFOLIO_NAME_MAX, + PORTFOLIO_SHARED_MAX_PLAYERS, +} from "@palacards/game"; import type { Page, PortfolioSummaryDTO } from "@palacards/shared"; import { Plus, X } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { toast } from "sonner"; -import useSWR from "swr"; +import Link from "next/link"; +import useSWR, { useSWRConfig } from "swr"; import useSWRInfinite from "swr/infinite"; -import { PortfolioRow, PortfolioTile } from "@/components/Portfolio"; +import { InvitationAnswer, PortfolioRow, PortfolioShell, PortfolioTile } from "@/components/Portfolio"; import { Empty, ErrorBox, LoadMore } from "@/components/ui"; import { api, ApiError } from "@/lib/api"; import { useSeenFeature } from "@/lib/features"; +import { useMe } from "@/lib/game"; const SORTS = [ { value: "recent", param: null, label: "Récents" }, @@ -133,6 +141,46 @@ function Mine({ onCreate }: { onCreate: () => void }) { ); } +/** Invitations d'amis dans leurs portfolios partagés (rien s'il n'y en a pas). */ +function Invitations() { + const { me } = useMe(); + const { data, mutate } = useSWR("/portfolios/invitations"); + const { mutate: refresh } = useSWRConfig(); + if (!data?.length || !me) return null; + return ( +
    +

    + Invitations +

    +
      + {data.map((p) => ( +
    • + +
      +

      {p.name}

      +

      + {p.owner.displayName} t’invite à y ajouter tes cartes.{" "} + + Voir la page + +

      +
      + { + void mutate(); + void refresh("/portfolios/mine"); + }} + /> +
      +
    • + ))} +
    +
    + ); +} + function Community() { const router = useRouter(); const params = useSearchParams(); @@ -205,9 +253,12 @@ export default function PortfoliosPage() {

    Des pages de classeur composées avec tes cartes : jusqu’à {PORTFOLIO_CARDS_MAX} par portfolio, une en vedette, - et un fond, un cadre et une couleur au choix. Les j’aime ne rapportent rien, ils font juste plaisir. + et un fond, un cadre et une couleur au choix. Invite jusqu’à {PORTFOLIO_SHARED_MAX_PLAYERS - 1} amis à y ajouter + leurs cartes. Les j’aime ne rapportent rien, ils font juste plaisir.

    + +

    Tes portfolios diff --git a/apps/web/src/app/(game)/settings/page.tsx b/apps/web/src/app/(game)/settings/page.tsx index 067cebc..b03968f 100644 --- a/apps/web/src/app/(game)/settings/page.tsx +++ b/apps/web/src/app/(game)/settings/page.tsx @@ -21,7 +21,7 @@ import { setTheme, useTheme } from "@/lib/theme"; const GROUPS: Record = { market: "Marché (enchères, ventes, wishlist)", trades: "Échanges", - social: "Amis et guilde", + social: "Amis, guilde et portfolios partagés", battles: "Duels", packs: "Stock de paquets plein", achievements: "Succès", diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 0120f76..1b76542 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1170,11 +1170,24 @@ button:disabled .pc-pack-foil { color: var(--color-danger); } } +/* Auteur d'une carte dans la barre (portfolio partagé, vue d'un contributeur) : même hauteur que les boutons. */ +.pc-arrange-label { + min-width: 0; + overflow: hidden; + padding: 0 0.5rem; + font-size: 0.75rem; + line-height: 2rem; + white-space: nowrap; + text-overflow: ellipsis; +} @media (pointer: coarse) { .pc-arrange-btn { min-width: 2.5rem; height: 2.5rem; } + .pc-arrange-label { + line-height: 2.5rem; + } } .pc-showcase-empty { position: relative; diff --git a/apps/web/src/components/Portfolio.tsx b/apps/web/src/components/Portfolio.tsx index 87fe531..6633ccb 100644 --- a/apps/web/src/components/Portfolio.tsx +++ b/apps/web/src/components/Portfolio.tsx @@ -2,7 +2,7 @@ // Importé uniquement par des composants client. import { PORTFOLIO_PROFILE_SLOTS } from "@palacards/game"; import type { PortfolioDesignDTO, PortfolioDTO, PortfolioSummaryDTO } from "@palacards/shared"; -import { ChevronLeft, ChevronRight, Heart, Link2, Pencil, Star, X } from "lucide-react"; +import { Check, ChevronLeft, ChevronRight, Heart, Link2, Lock, Pencil, Star, Users, X } from "lucide-react"; import Link from "next/link"; import { useState, type CSSProperties, type ReactNode } from "react"; import useSWR from "swr"; @@ -46,6 +46,19 @@ export function PortfolioShell({ const likesLabel = (n: number) => `${fmt(n)} j’aime`; +/** Le visiteur compose ce portfolio (propriétaire ou ami qui l'a rejoint). */ +export const participates = (p: PortfolioSummaryDTO) => p.role === "owner" || p.role === "member"; + +const joinNames = (names: string[]) => + names.length > 1 ? `${names.slice(0, -1).join(", ")} et ${names.at(-1)}` : (names[0] ?? ""); + +/** Ceux qui composent le portfolio : « Léa », « Léa et Max », « Léa, Max et Sam ». */ +export const participantsLabel = (p: PortfolioSummaryDTO) => + joinNames([p.owner, ...p.members].map((m) => m.displayName)); + +/** Amis qui participent au portfolio (sans le propriétaire). */ +const sharedWith = (p: PortfolioSummaryDTO) => joinNames(p.members.map((m) => m.displayName)); + /** J'aime (sans récompense) : un par joueur, jamais sur les siens ; le compteur suit tout de suite. */ export function LikeButton({ p, @@ -58,9 +71,9 @@ export function LikeButton({ const [busy, setBusy] = useState(false); // Nouveau portfolio affiché : on repart de ses valeurs. if (state.for !== p.id) setState({ likes: p.likes, liked: p.liked, for: p.id }); - if (p.mine) + if (participates(p)) return ( - + {fmt(state.likes)} j’aime @@ -117,7 +130,7 @@ export function PortfolioTile({ p, heading: Heading = "h3" }: { p: PortfolioSumm

    - {p.owner.displayName} · {p.cardCount} carte{p.cardCount > 1 ? "s" : ""} + {participantsLabel(p)} · {p.cardCount} carte{p.cardCount > 1 ? "s" : ""}

    @@ -164,14 +177,28 @@ export function PortfolioView({ p }: { p: PortfolioDTO }) {

    {p.name}

    - - - {p.owner.displayName} - + {/* Propriétaire puis amis participants : « Léa, Max et Sam ». */} + {[p.owner, ...p.members].map((m, i, all) => ( + + + + {m.displayName} + + {i < all.length - 2 ? "," : i === all.length - 2 ? et : null} + + ))} · {p.cardCount} carte{p.cardCount > 1 ? "s" : ""} + {p.visibility === "members" && ( + <> + · + + Visible des participants seuls + + + )}

    {p.description &&

    {p.description}

    } @@ -197,20 +224,56 @@ export function PortfolioView({ p }: { p: PortfolioDTO }) { ); } -/** Actions sous la page d'un portfolio : partager, et modifier s'il est à soi. */ +/** Actions sous la page d'un portfolio : partager, et modifier si on le compose. */ export function PortfolioActions({ p }: { p: PortfolioSummaryDTO }) { return (
    - {p.mine && ( + {participates(p) && ( - Modifier + {p.mine ? "Modifier" : "Mes cartes"} )}
    ); } +/** Réponse à l'invitation d'un ami dans son portfolio partagé : rejoindre ou refuser. */ +export function InvitationAnswer({ + p, + me, + onDone, +}: { + p: PortfolioSummaryDTO; + me: string; + onDone: (joined: boolean) => void; +}) { + const [busy, setBusy] = useState(false); + async function answer(join: boolean) { + setBusy(true); + try { + if (join) await api(`/portfolios/${p.id}/join`, { method: "POST" }); + else await api(`/portfolios/${p.id}/members/${encodeURIComponent(me)}`, { method: "DELETE" }); + toast.success(join ? `Tu participes à « ${p.name} ».` : "Invitation refusée."); + onDone(join); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : "Réponse non enregistrée."); + } finally { + setBusy(false); + } + } + return ( +
    + + +
    + ); +} + /** Ligne compacte de ses propres portfolios : la page en petit, trois cartes en éventail et les actions. */ export function PortfolioRow({ p }: { p: PortfolioSummaryDTO }) { return ( @@ -224,13 +287,23 @@ export function PortfolioRow({ p }: { p: PortfolioSummaryDTO }) {

    {p.name}

    - {p.cardCount ? `${p.cardCount} carte${p.cardCount > 1 ? "s" : ""}` : "Vide, visible de toi seul"} ·{" "} - {likesLabel(p.likes)} - {p.profileSlot !== null && " · sur ton profil"} + {p.cardCount + ? `${p.cardCount} carte${p.cardCount > 1 ? "s" : ""}` + : p.members.length + ? "Vide" + : "Vide, visible de toi seul"}{" "} + · {likesLabel(p.likes)} + {participates(p) && p.profileSlot !== null && " · sur ton profil"}

    + {(!p.mine || p.members.length > 0) && ( +

    + + {p.mine ? `Avec ${sharedWith(p)}` : `Partagé par ${p.owner.displayName}`} +

    + )}

    - Modifier + {p.mine ? "Modifier" : "Mes cartes"} {p.cardCount > 0 && ( @@ -272,8 +345,10 @@ export function MyProfilePortfolios() { const [adding, setAdding] = useState(""); const [busy, setBusy] = useState(false); if (!data) return null; - const shown = data.filter((p) => p.profileSlot !== null); - const others = data.filter((p) => p.profileSlot === null && p.cardCount > 0); + // Les siens et les portfolios partagés d'amis qu'on a rejoints ; publics et non vides pour en ajouter. + const own = data.filter(participates); + const shown = own.filter((p) => p.profileSlot !== null); + const others = own.filter((p) => p.profileSlot === null && p.cardCount > 0 && p.visibility === "public"); async function save(ids: number[]) { setBusy(true); @@ -302,7 +377,7 @@ export function MyProfilePortfolios() { {shown.length} / {PORTFOLIO_PROFILE_SLOTS}

    - {data.length > 0 && ( + {own.length > 0 && (