This commit is contained in:
+12
-3
@@ -1,3 +1,10 @@
|
|||||||
|
# Stack complet Skori, à lancer sur l'hôte 192.168.10.174 :
|
||||||
|
# docker compose up -d --build
|
||||||
|
# Accès : http://192.168.10.174:8080
|
||||||
|
#
|
||||||
|
# Simplifié pour l'instant : mots de passe en dur (pas de .env), déploiement HTTP
|
||||||
|
# sur le LAN. nginx sert le frontend et proxifie /api vers le backend (même
|
||||||
|
# origine), donc le navigateur ne contacte que 192.168.10.174:8080.
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -21,8 +28,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
DATABASE_URL: postgres://skori:${POSTGRES_PASSWORD:-skori}@postgres:5432/skori
|
DATABASE_URL: postgres://skori:skori@postgres:5432/skori
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: skori-lan-secret-please-change-later
|
||||||
|
# HTTP sur le LAN : le cookie refresh ne doit pas être en Secure
|
||||||
|
COOKIE_SECURE: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -33,7 +42,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: skori
|
POSTGRES_USER: skori
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skori}
|
POSTGRES_PASSWORD: skori
|
||||||
POSTGRES_DB: skori
|
POSTGRES_DB: skori
|
||||||
volumes:
|
volumes:
|
||||||
- skori-pgdata:/var/lib/postgresql/data
|
- skori-pgdata:/var/lib/postgresql/data
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { errorHandler } from "./middleware/errorHandler.js";
|
|||||||
import { authRouter } from "./modules/auth/routes.js";
|
import { authRouter } from "./modules/auth/routes.js";
|
||||||
import { usersRouter } from "./modules/users/routes.js";
|
import { usersRouter } from "./modules/users/routes.js";
|
||||||
import { syncRouter } from "./modules/sync/routes.js";
|
import { syncRouter } from "./modules/sync/routes.js";
|
||||||
|
import { friendsRouter } from "./modules/friends/routes.js";
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -24,6 +25,7 @@ export function createApp() {
|
|||||||
app.use("/api/v1/auth", authRouter);
|
app.use("/api/v1/auth", authRouter);
|
||||||
app.use("/api/v1", usersRouter);
|
app.use("/api/v1", usersRouter);
|
||||||
app.use("/api/v1/sync", syncRouter);
|
app.use("/api/v1/sync", syncRouter);
|
||||||
|
app.use("/api/v1/friends", friendsRouter);
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export const env = {
|
|||||||
databaseUrl: process.env.DATABASE_URL || "",
|
databaseUrl: process.env.DATABASE_URL || "",
|
||||||
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me",
|
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me",
|
||||||
isProduction: process.env.NODE_ENV === "production",
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
|
// Whether the refresh cookie is flagged Secure (HTTPS only). Defaults to
|
||||||
|
// production, but can be forced off for a plain-HTTP LAN deployment.
|
||||||
|
cookieSecure:
|
||||||
|
process.env.COOKIE_SECURE !== undefined
|
||||||
|
? process.env.COOKIE_SECURE === "true"
|
||||||
|
: process.env.NODE_ENV === "production",
|
||||||
corsOrigins: (process.env.CORS_ORIGINS || "")
|
corsOrigins: (process.env.CORS_ORIGINS || "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ const STATEMENTS = [
|
|||||||
deleted_at bigint
|
deleted_at bigint
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`,
|
`CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS friendships (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
requester_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
addressee_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status text NOT NULL,
|
||||||
|
created_at bigint NOT NULL,
|
||||||
|
updated_at bigint NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS friendships_requester_idx ON friendships (requester_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS friendships_addressee_idx ON friendships (addressee_id)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS friendships_pair_uidx ON friendships (
|
||||||
|
LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)
|
||||||
|
)`,
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function ensureSchema(db: AppDatabase) {
|
export async function ensureSchema(db: AppDatabase) {
|
||||||
|
|||||||
@@ -74,6 +74,27 @@ export const players = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const friendships = pgTable(
|
||||||
|
"friendships",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
requesterId: uuid("requester_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
addresseeId: uuid("addressee_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
// "pending" | "accepted" | "blocked"
|
||||||
|
status: text("status").notNull(),
|
||||||
|
createdAt: ms("created_at").notNull(),
|
||||||
|
updatedAt: ms("updated_at").notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
requesterIdx: index("friendships_requester_idx").on(t.requesterId),
|
||||||
|
addresseeIdx: index("friendships_addressee_idx").on(t.addresseeId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const sessions = pgTable(
|
export const sessions = pgTable(
|
||||||
"sessions",
|
"sessions",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const loginSchema = z.object({
|
|||||||
function setRefreshCookie(res: Response, token: string) {
|
function setRefreshCookie(res: Response, token: string) {
|
||||||
res.cookie(REFRESH_COOKIE_NAME, token, {
|
res.cookie(REFRESH_COOKIE_NAME, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.isProduction,
|
secure: env.cookieSecure,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: REFRESH_TOKEN_TTL_MS,
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
||||||
path: "/api/v1/auth",
|
path: "/api/v1/auth",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import * as friends from "./service.js";
|
||||||
|
|
||||||
|
const sendSchema = z.object({ username: z.string().min(1) });
|
||||||
|
|
||||||
|
export async function list(req: Request, res: Response) {
|
||||||
|
res.json({ friends: await friends.listFriends(req.userId!) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requests(req: Request, res: Response) {
|
||||||
|
res.json(await friends.listRequests(req.userId!));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(req: Request, res: Response) {
|
||||||
|
const q = String(req.query.q ?? "");
|
||||||
|
res.json({ results: await friends.searchUsers(req.userId!, q) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRequest(req: Request, res: Response) {
|
||||||
|
const { username } = sendSchema.parse(req.body);
|
||||||
|
const result = await friends.sendRequest(req.userId!, username);
|
||||||
|
res.status(201).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accept(req: Request, res: Response) {
|
||||||
|
await friends.acceptRequest(req.userId!, req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decline(req: Request, res: Response) {
|
||||||
|
await friends.declineRequest(req.userId!, req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfriend(req: Request, res: Response) {
|
||||||
|
await friends.removeFriend(req.userId!, req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function block(req: Request, res: Response) {
|
||||||
|
await friends.blockFriend(req.userId!, req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { requireAuth } from "../../middleware/auth.js";
|
||||||
|
import { asyncHandler } from "../../middleware/errorHandler.js";
|
||||||
|
import * as controller from "./controller.js";
|
||||||
|
|
||||||
|
export const friendsRouter = Router();
|
||||||
|
|
||||||
|
friendsRouter.use(requireAuth);
|
||||||
|
|
||||||
|
friendsRouter.get("/", asyncHandler(controller.list));
|
||||||
|
friendsRouter.get("/requests", asyncHandler(controller.requests));
|
||||||
|
friendsRouter.get("/search", asyncHandler(controller.search));
|
||||||
|
friendsRouter.post("/requests", asyncHandler(controller.sendRequest));
|
||||||
|
friendsRouter.post("/requests/:id/accept", asyncHandler(controller.accept));
|
||||||
|
friendsRouter.post("/requests/:id/decline", asyncHandler(controller.decline));
|
||||||
|
friendsRouter.delete("/:id", asyncHandler(controller.unfriend));
|
||||||
|
friendsRouter.post("/:id/block", asyncHandler(controller.block));
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { and, eq, ilike, inArray, or, ne } from "drizzle-orm";
|
||||||
|
import { getDb } from "../../db/client.js";
|
||||||
|
import { users, friendships } from "../../db/schema.js";
|
||||||
|
import { HttpError } from "../../middleware/errorHandler.js";
|
||||||
|
import { newId } from "../../utils/tokens.js";
|
||||||
|
|
||||||
|
export interface FriendUser {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFriendUser(u: typeof users.$inferSelect): FriendUser {
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
displayName: u.displayName,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usersByIds(ids: string[]): Promise<Map<string, FriendUser>> {
|
||||||
|
if (ids.length === 0) return new Map();
|
||||||
|
const db = getDb();
|
||||||
|
const rows = await db.select().from(users).where(inArray(users.id, ids));
|
||||||
|
return new Map(rows.map((u) => [u.id, toFriendUser(u)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// All friendship rows involving `meId`, in either direction.
|
||||||
|
async function myFriendships(meId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(friendships)
|
||||||
|
.where(
|
||||||
|
or(eq(friendships.requesterId, meId), eq(friendships.addresseeId, meId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFriends(meId: string) {
|
||||||
|
const rows = (await myFriendships(meId)).filter(
|
||||||
|
(f) => f.status === "accepted",
|
||||||
|
);
|
||||||
|
const otherIds = rows.map((f) =>
|
||||||
|
f.requesterId === meId ? f.addresseeId : f.requesterId,
|
||||||
|
);
|
||||||
|
const userMap = await usersByIds(otherIds);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((f) => {
|
||||||
|
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
|
||||||
|
const user = userMap.get(otherId);
|
||||||
|
return user ? { friendshipId: f.id, user } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRequests(meId: string) {
|
||||||
|
const rows = (await myFriendships(meId)).filter(
|
||||||
|
(f) => f.status === "pending",
|
||||||
|
);
|
||||||
|
const otherIds = rows.map((f) =>
|
||||||
|
f.requesterId === meId ? f.addresseeId : f.requesterId,
|
||||||
|
);
|
||||||
|
const userMap = await usersByIds(otherIds);
|
||||||
|
|
||||||
|
const incoming: any[] = [];
|
||||||
|
const outgoing: any[] = [];
|
||||||
|
for (const f of rows) {
|
||||||
|
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
|
||||||
|
const user = userMap.get(otherId);
|
||||||
|
if (!user) continue;
|
||||||
|
if (f.addresseeId === meId) incoming.push({ friendshipId: f.id, user });
|
||||||
|
else outgoing.push({ friendshipId: f.id, user });
|
||||||
|
}
|
||||||
|
return { incoming, outgoing };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUsers(meId: string, query: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const q = query.trim();
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
|
||||||
|
const found = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(and(ilike(users.username, `${q}%`), ne(users.id, meId)))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
// Annotate each result with the current relationship (for button state).
|
||||||
|
const rels = await myFriendships(meId);
|
||||||
|
const relByOther = new Map<string, string>();
|
||||||
|
for (const f of rels) {
|
||||||
|
const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId;
|
||||||
|
const label =
|
||||||
|
f.status === "accepted"
|
||||||
|
? "friends"
|
||||||
|
: f.status === "blocked"
|
||||||
|
? "blocked"
|
||||||
|
: f.requesterId === meId
|
||||||
|
? "pending_out"
|
||||||
|
: "pending_in";
|
||||||
|
relByOther.set(otherId, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.map((u) => ({
|
||||||
|
...toFriendUser(u),
|
||||||
|
relationship: relByOther.get(u.id) ?? "none",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRequest(meId: string, username: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [target] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) throw new HttpError(404, "user_not_found");
|
||||||
|
if (target.id === meId) throw new HttpError(400, "cannot_add_self");
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(friendships)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
and(
|
||||||
|
eq(friendships.requesterId, meId),
|
||||||
|
eq(friendships.addresseeId, target.id),
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
eq(friendships.requesterId, target.id),
|
||||||
|
eq(friendships.addresseeId, meId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === "accepted")
|
||||||
|
throw new HttpError(409, "already_friends");
|
||||||
|
if (existing.status === "blocked") throw new HttpError(403, "blocked");
|
||||||
|
throw new HttpError(409, "request_already_exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const [row] = await db
|
||||||
|
.insert(friendships)
|
||||||
|
.values({
|
||||||
|
id: newId(),
|
||||||
|
requesterId: meId,
|
||||||
|
addresseeId: target.id,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return { friendshipId: row.id, user: toFriendUser(target) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptRequest(meId: string, friendshipId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [f] = await db
|
||||||
|
.select()
|
||||||
|
.from(friendships)
|
||||||
|
.where(eq(friendships.id, friendshipId))
|
||||||
|
.limit(1);
|
||||||
|
if (!f || f.addresseeId !== meId || f.status !== "pending") {
|
||||||
|
throw new HttpError(404, "request_not_found");
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(friendships)
|
||||||
|
.set({ status: "accepted", updatedAt: Date.now() })
|
||||||
|
.where(eq(friendships.id, friendshipId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function declineRequest(meId: string, friendshipId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [f] = await db
|
||||||
|
.select()
|
||||||
|
.from(friendships)
|
||||||
|
.where(eq(friendships.id, friendshipId))
|
||||||
|
.limit(1);
|
||||||
|
if (!f || f.addresseeId !== meId || f.status !== "pending") {
|
||||||
|
throw new HttpError(404, "request_not_found");
|
||||||
|
}
|
||||||
|
await db.delete(friendships).where(eq(friendships.id, friendshipId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFriend(meId: string, friendshipId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [f] = await db
|
||||||
|
.select()
|
||||||
|
.from(friendships)
|
||||||
|
.where(eq(friendships.id, friendshipId))
|
||||||
|
.limit(1);
|
||||||
|
if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) {
|
||||||
|
throw new HttpError(404, "friendship_not_found");
|
||||||
|
}
|
||||||
|
await db.delete(friendships).where(eq(friendships.id, friendshipId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blockFriend(meId: string, friendshipId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [f] = await db
|
||||||
|
.select()
|
||||||
|
.from(friendships)
|
||||||
|
.where(eq(friendships.id, friendshipId))
|
||||||
|
.limit(1);
|
||||||
|
if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) {
|
||||||
|
throw new HttpError(404, "friendship_not_found");
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(friendships)
|
||||||
|
.set({ status: "blocked", updatedAt: Date.now() })
|
||||||
|
.where(eq(friendships.id, friendshipId));
|
||||||
|
}
|
||||||
@@ -160,6 +160,78 @@ async function main() {
|
|||||||
// --- H. Auth required on sync ---
|
// --- H. Auth required on sync ---
|
||||||
const noAuth = await req("GET", "/sync?since=0");
|
const noAuth = await req("GET", "/sync?since=0");
|
||||||
check("sync without token is 401", noAuth.status === 401);
|
check("sync without token is 401", noAuth.status === 401);
|
||||||
|
|
||||||
|
// --- I. Friends: two accounts befriend each other ---
|
||||||
|
const emailF = `friend${Date.now()}@test.dev`;
|
||||||
|
const regF = await req("POST", "/auth/register", {
|
||||||
|
email: emailF,
|
||||||
|
password: "supersecret",
|
||||||
|
displayName: "Zoe",
|
||||||
|
username: `zoe_${Date.now().toString(36)}`,
|
||||||
|
});
|
||||||
|
check("second account registers with username", regF.status === 201);
|
||||||
|
const tokenF = regF.json.accessToken;
|
||||||
|
const zoeUsername = regF.json.user.username;
|
||||||
|
|
||||||
|
// Alice needs a username too so Zoe could find her back (not required here)
|
||||||
|
await req("PATCH", "/me", { username: `alice_${Date.now().toString(36)}` }, tokenA);
|
||||||
|
|
||||||
|
// Alice searches for Zoe by username
|
||||||
|
const searchRes = await req(
|
||||||
|
"GET",
|
||||||
|
`/friends/search?q=${zoeUsername}`,
|
||||||
|
undefined,
|
||||||
|
tokenA,
|
||||||
|
);
|
||||||
|
check("search finds the user by username", searchRes.json?.results?.some((u: any) => u.id === regF.json.user.id));
|
||||||
|
|
||||||
|
// Alice sends a friend request to Zoe
|
||||||
|
const sendRes = await req(
|
||||||
|
"POST",
|
||||||
|
"/friends/requests",
|
||||||
|
{ username: zoeUsername },
|
||||||
|
tokenA,
|
||||||
|
);
|
||||||
|
check("friend request created", sendRes.status === 201);
|
||||||
|
const friendshipId = sendRes.json.friendshipId;
|
||||||
|
|
||||||
|
// Duplicate request is rejected
|
||||||
|
const dupRes = await req(
|
||||||
|
"POST",
|
||||||
|
"/friends/requests",
|
||||||
|
{ username: zoeUsername },
|
||||||
|
tokenA,
|
||||||
|
);
|
||||||
|
check("duplicate friend request rejected", dupRes.status === 409);
|
||||||
|
|
||||||
|
// Zoe sees the incoming request
|
||||||
|
const zoeReqs = await req("GET", "/friends/requests", undefined, tokenF);
|
||||||
|
check("addressee sees incoming request", zoeReqs.json?.incoming?.some((r: any) => r.friendshipId === friendshipId));
|
||||||
|
|
||||||
|
// Alice sees it as outgoing
|
||||||
|
const aliceReqs = await req("GET", "/friends/requests", undefined, tokenA);
|
||||||
|
check("requester sees outgoing request", aliceReqs.json?.outgoing?.some((r: any) => r.friendshipId === friendshipId));
|
||||||
|
|
||||||
|
// Zoe accepts
|
||||||
|
const acceptRes = await req(
|
||||||
|
"POST",
|
||||||
|
`/friends/requests/${friendshipId}/accept`,
|
||||||
|
undefined,
|
||||||
|
tokenF,
|
||||||
|
);
|
||||||
|
check("accept returns ok", acceptRes.status === 200);
|
||||||
|
|
||||||
|
// Both now list each other as friends
|
||||||
|
const aliceFriends = await req("GET", "/friends", undefined, tokenA);
|
||||||
|
const zoeFriends = await req("GET", "/friends", undefined, tokenF);
|
||||||
|
check("requester now has the friend", aliceFriends.json?.friends?.some((f: any) => f.user.id === regF.json.user.id));
|
||||||
|
check("addressee now has the friend", zoeFriends.json?.friends?.some((f: any) => f.friendshipId === friendshipId));
|
||||||
|
|
||||||
|
// Unfriend
|
||||||
|
const unfriendRes = await req("DELETE", `/friends/${friendshipId}`, undefined, tokenA);
|
||||||
|
check("unfriend returns ok", unfriendRes.status === 200);
|
||||||
|
const aliceAfter = await req("GET", "/friends", undefined, tokenA);
|
||||||
|
check("friend removed after unfriend", !aliceAfter.json?.friends?.some((f: any) => f.friendshipId === friendshipId));
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import History from "./pages/History";
|
|||||||
import Players from "./pages/Players";
|
import Players from "./pages/Players";
|
||||||
import Locations from "./pages/Locations";
|
import Locations from "./pages/Locations";
|
||||||
import Profiles from "./pages/Profiles";
|
import Profiles from "./pages/Profiles";
|
||||||
|
import Friends from "./pages/Friends";
|
||||||
import Auth from "./pages/Auth";
|
import Auth from "./pages/Auth";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import Statistics from "./pages/Stats";
|
import Statistics from "./pages/Stats";
|
||||||
@@ -41,6 +42,7 @@ function App() {
|
|||||||
<Route path="/players" element={<Players />} />
|
<Route path="/players" element={<Players />} />
|
||||||
<Route path="/locations" element={<Locations />} />
|
<Route path="/locations" element={<Locations />} />
|
||||||
<Route path="/profiles" element={<Profiles />} />
|
<Route path="/profiles" element={<Profiles />} />
|
||||||
|
<Route path="/friends" element={<Friends />} />
|
||||||
<Route path="/login" element={<Auth />} />
|
<Route path="/login" element={<Auth />} />
|
||||||
<Route path="/stats" element={<Statistics />} />
|
<Route path="/stats" element={<Statistics />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Users2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@@ -101,6 +102,12 @@ export function NavigationMenu() {
|
|||||||
>
|
>
|
||||||
<MapPin className="w-5 h-5 mr-3 text-primary" /> Emplacements
|
<MapPin className="w-5 h-5 mr-3 text-primary" /> Emplacements
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => nav("/friends")}
|
||||||
|
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
||||||
|
>
|
||||||
|
<Users2 className="w-5 h-5 mr-3 text-primary" /> Amis
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => nav("/stats")}
|
onClick={() => nav("/stats")}
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
||||||
|
|||||||
+3
-1
@@ -53,7 +53,9 @@ export default function Auth() {
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
username: values.username || undefined,
|
username: values.username || undefined,
|
||||||
displayName: activeProfile?.name,
|
// Prefer the username as public display name (the local profile name
|
||||||
|
// defaults to "Moi" and would be identical for everyone).
|
||||||
|
displayName: values.username || activeProfile?.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
navigate("/settings");
|
navigate("/settings");
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Users2,
|
||||||
|
Search,
|
||||||
|
UserPlus,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Clock,
|
||||||
|
LogIn,
|
||||||
|
AtSign,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import {
|
||||||
|
useFriendsStore,
|
||||||
|
SearchResult,
|
||||||
|
} from "../stores/friendsStore";
|
||||||
|
import { ApiError } from "../lib/apiClient";
|
||||||
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Avatar } from "../components/ui/avatar";
|
||||||
|
import { NavigationMenu } from "../components/NavigationMenu";
|
||||||
|
|
||||||
|
export default function Friends() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { status, user, updateAccount } = useAuthStore();
|
||||||
|
const { friends, incoming, outgoing, load, search, sendRequest, accept, decline, unfriend } =
|
||||||
|
useFriendsStore();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [usernameInput, setUsernameInput] = useState("");
|
||||||
|
const [savingUsername, setSavingUsername] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") load();
|
||||||
|
}, [status, load]);
|
||||||
|
|
||||||
|
if (status !== "authenticated") {
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6 pb-20 relative z-50">
|
||||||
|
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
||||||
|
<NavigationMenu />
|
||||||
|
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
||||||
|
Amis
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
||||||
|
<CardContent className="p-6 text-center space-y-4">
|
||||||
|
<Users2 className="w-14 h-14 mx-auto opacity-20" />
|
||||||
|
<p className="font-bold">
|
||||||
|
Connectez-vous pour ajouter des amis et les retrouver dans vos
|
||||||
|
parties.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="w-full h-12 rounded-2xl font-black"
|
||||||
|
onClick={() => navigate("/login")}
|
||||||
|
>
|
||||||
|
<LogIn className="w-5 h-5 mr-2" />
|
||||||
|
Se connecter
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
setResults(await search(query));
|
||||||
|
} catch {
|
||||||
|
setError("Recherche impossible.");
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async (username: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await sendRequest(username);
|
||||||
|
setResults((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.username === username ? { ...r, relationship: "pending_out" } : r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const code = e instanceof ApiError ? e.message : "";
|
||||||
|
setError(
|
||||||
|
code === "already_friends"
|
||||||
|
? "Vous êtes déjà amis."
|
||||||
|
: code === "request_already_exists"
|
||||||
|
? "Demande déjà envoyée."
|
||||||
|
: "Impossible d'envoyer la demande.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveUsername = async () => {
|
||||||
|
const u = usernameInput.trim();
|
||||||
|
if (!u) return;
|
||||||
|
setSavingUsername(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateAccount({ username: u });
|
||||||
|
} catch (e) {
|
||||||
|
const code = e instanceof ApiError ? e.message : "";
|
||||||
|
setError(
|
||||||
|
code === "username_taken"
|
||||||
|
? "Ce pseudo est déjà pris."
|
||||||
|
: "Impossible d'enregistrer le pseudo.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSavingUsername(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6 pb-20 relative z-50">
|
||||||
|
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
||||||
|
<NavigationMenu />
|
||||||
|
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
||||||
|
Amis
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive font-medium bg-destructive/10 rounded-xl p-3">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prompt to set a username so others can find you */}
|
||||||
|
{!user?.username ? (
|
||||||
|
<Card className="border-0 shadow-lg bg-primary/5 backdrop-blur-md">
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<p className="text-sm font-bold flex items-center">
|
||||||
|
<AtSign className="w-4 h-4 mr-2 text-primary" />
|
||||||
|
Choisissez un pseudo pour que vos amis vous trouvent
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
value={usernameInput}
|
||||||
|
onChange={(e) => setUsernameInput(e.target.value)}
|
||||||
|
placeholder="votre_pseudo"
|
||||||
|
className="h-12"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveUsername}
|
||||||
|
disabled={savingUsername || !usernameInput.trim()}
|
||||||
|
className="rounded-2xl font-bold"
|
||||||
|
>
|
||||||
|
{savingUsername ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground px-2">
|
||||||
|
Votre pseudo : <span className="font-bold">@{user.username}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
placeholder="Rechercher un pseudo…"
|
||||||
|
className="h-12"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={searching || query.trim().length < 2}
|
||||||
|
className="rounded-2xl font-bold"
|
||||||
|
>
|
||||||
|
{searching ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{results.map((r) => (
|
||||||
|
<Card key={r.id} className="border-0 shadow-md bg-background/90">
|
||||||
|
<CardContent className="p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center min-w-0">
|
||||||
|
<Avatar src={r.avatarUrl ?? undefined} name={r.displayName} size="md" className="mr-3" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold truncate">{r.displayName}</p>
|
||||||
|
{r.username && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
@{r.username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{r.relationship === "friends" ? (
|
||||||
|
<span className="text-xs font-bold text-emerald-600">Ami</span>
|
||||||
|
) : r.relationship === "pending_out" ? (
|
||||||
|
<span className="text-xs font-bold text-muted-foreground">
|
||||||
|
Envoyée
|
||||||
|
</span>
|
||||||
|
) : r.relationship === "pending_in" ? (
|
||||||
|
<span className="text-xs font-bold text-primary">
|
||||||
|
Vous a ajouté
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="rounded-full font-bold"
|
||||||
|
onClick={() => r.username && handleSend(r.username)}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4 mr-1" />
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Incoming requests */}
|
||||||
|
{incoming.length > 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold px-1">Demandes reçues</h2>
|
||||||
|
{incoming.map((r) => (
|
||||||
|
<Card key={r.friendshipId} className="border-0 shadow-md bg-background/90">
|
||||||
|
<CardContent className="p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center min-w-0">
|
||||||
|
<Avatar src={r.user.avatarUrl ?? undefined} name={r.user.displayName} size="md" className="mr-3" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold truncate">{r.user.displayName}</p>
|
||||||
|
{r.user.username && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
@{r.user.username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-green-600 bg-green-500/10 hover:bg-green-500/20 rounded-xl"
|
||||||
|
onClick={() => accept(r.friendshipId)}
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:bg-destructive/10 rounded-xl"
|
||||||
|
onClick={() => decline(r.friendshipId)}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Outgoing requests */}
|
||||||
|
{outgoing.length > 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold px-1">Demandes envoyées</h2>
|
||||||
|
{outgoing.map((r) => (
|
||||||
|
<Card key={r.friendshipId} className="border-0 shadow-sm bg-background/70">
|
||||||
|
<CardContent className="p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center min-w-0">
|
||||||
|
<Avatar src={r.user.avatarUrl ?? undefined} name={r.user.displayName} size="md" className="mr-3 opacity-70" />
|
||||||
|
<p className="font-bold truncate">{r.user.displayName}</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center text-xs font-bold text-muted-foreground shrink-0">
|
||||||
|
<Clock className="w-3.5 h-3.5 mr-1" />
|
||||||
|
En attente
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Friends list */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold px-1">
|
||||||
|
Mes amis {friends.length > 0 && `(${friends.length})`}
|
||||||
|
</h2>
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8 bg-background/60 backdrop-blur-sm rounded-[2rem]">
|
||||||
|
<Users2 className="w-12 h-12 mx-auto mb-2 opacity-20" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Aucun ami pour l'instant. Recherchez un pseudo ci-dessus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
friends.map((f) => (
|
||||||
|
<Card key={f.friendshipId} className="border-0 shadow-md bg-background/90">
|
||||||
|
<CardContent className="p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center min-w-0">
|
||||||
|
<Avatar src={f.user.avatarUrl ?? undefined} name={f.user.displayName} size="md" className="mr-3" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold truncate">{f.user.displayName}</p>
|
||||||
|
{f.user.username && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
@{f.user.username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:bg-destructive/10 rounded-full text-xs font-bold shrink-0"
|
||||||
|
onClick={() => unfriend(f.friendshipId)}
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+60
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -17,6 +17,8 @@ import { db } from "../database/db";
|
|||||||
import { getGameConfig } from "../games";
|
import { getGameConfig } from "../games";
|
||||||
import { useGameStore } from "../stores/gameStore";
|
import { useGameStore } from "../stores/gameStore";
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import { useFriendsStore, FriendEntry } from "../stores/friendsStore";
|
||||||
import { useGameTheme } from "../hooks/useGameTheme";
|
import { useGameTheme } from "../hooks/useGameTheme";
|
||||||
import { Player, SavedPlayer } from "../types";
|
import { Player, SavedPlayer } from "../types";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
@@ -85,6 +87,12 @@ export default function NewGame() {
|
|||||||
|
|
||||||
const startNewGame = useGameStore((state) => state.startNewGame);
|
const startNewGame = useGameStore((state) => state.startNewGame);
|
||||||
const activeProfileId = useProfileStore((state) => state.activeProfileId);
|
const activeProfileId = useProfileStore((state) => state.activeProfileId);
|
||||||
|
const authStatus = useAuthStore((state) => state.status);
|
||||||
|
const { friends, load: loadFriends } = useFriendsStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authStatus === "authenticated") loadFriends();
|
||||||
|
}, [authStatus, loadFriends]);
|
||||||
|
|
||||||
const [players, setPlayers] = useState<Player[]>([
|
const [players, setPlayers] = useState<Player[]>([
|
||||||
{ id: generateId(), name: "" },
|
{ id: generateId(), name: "" },
|
||||||
@@ -123,6 +131,9 @@ export default function NewGame() {
|
|||||||
(p) => p.name.trim().toLowerCase() === sp.name.trim().toLowerCase(),
|
(p) => p.name.trim().toLowerCase() === sp.name.trim().toLowerCase(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const availableFriends = friends.filter(
|
||||||
|
(f) => !players.some((p) => p.linkedUserId === f.user.id),
|
||||||
|
);
|
||||||
|
|
||||||
if (!gameConfig) {
|
if (!gameConfig) {
|
||||||
return <div className="p-4 text-center">Jeu introuvable</div>;
|
return <div className="p-4 text-center">Jeu introuvable</div>;
|
||||||
@@ -158,6 +169,25 @@ export default function NewGame() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickAddFriend = (friend: FriendEntry) => {
|
||||||
|
// Snapshot the friend's name/avatar into the session, tagged with their
|
||||||
|
// server account id (linkedUserId).
|
||||||
|
const newPlayer: Player = {
|
||||||
|
id: generateId(),
|
||||||
|
name: friend.user.displayName,
|
||||||
|
avatar: friend.user.avatarUrl ?? undefined,
|
||||||
|
linkedUserId: friend.user.id,
|
||||||
|
};
|
||||||
|
const emptyIndex = players.findIndex((p) => p.name.trim() === "");
|
||||||
|
if (emptyIndex !== -1) {
|
||||||
|
const newPlayers = [...players];
|
||||||
|
newPlayers[emptyIndex] = newPlayer;
|
||||||
|
setPlayers(newPlayers);
|
||||||
|
} else if (players.length < gameConfig.maxPlayers) {
|
||||||
|
setPlayers([...players, newPlayer]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemovePlayer = (id: string) => {
|
const handleRemovePlayer = (id: string) => {
|
||||||
if (players.length > gameConfig.minPlayers) {
|
if (players.length > gameConfig.minPlayers) {
|
||||||
setPlayers(players.filter((p) => p.id !== id));
|
setPlayers(players.filter((p) => p.id !== id));
|
||||||
@@ -359,6 +389,35 @@ export default function NewGame() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{availableFriends.length > 0 && (
|
||||||
|
<div className="mt-6 pt-2 px-1">
|
||||||
|
<p className="text-sm font-bold text-muted-foreground mb-3 flex items-center">
|
||||||
|
<Users className="w-4 h-4 mr-2" /> Amis
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableFriends.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.user.id}
|
||||||
|
className="flex items-center bg-background/80 backdrop-blur-md hover:bg-primary/10 hover:text-primary px-3 py-2 rounded-full cursor-pointer active:scale-95 transition-all text-sm font-bold border-2 border-transparent hover:border-primary/20 shadow-sm"
|
||||||
|
onClick={() => handleQuickAddFriend(f)}
|
||||||
|
>
|
||||||
|
{f.user.avatarUrl ? (
|
||||||
|
<Avatar
|
||||||
|
src={f.user.avatarUrl}
|
||||||
|
name={f.user.displayName}
|
||||||
|
size="sm"
|
||||||
|
className="mr-2 border-none w-6 h-6 shadow-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Plus className="w-4 h-4 mr-1.5 opacity-50" />
|
||||||
|
)}
|
||||||
|
{f.user.displayName}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{locations.length > 0 && (
|
{locations.length > 0 && (
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ interface AuthState {
|
|||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
restore: () => Promise<void>;
|
restore: () => Promise<void>;
|
||||||
|
updateAccount: (changes: {
|
||||||
|
username?: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkActiveProfileToUser(userId: string) {
|
async function linkActiveProfileToUser(userId: string) {
|
||||||
@@ -93,6 +98,14 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ user: null, status: "anonymous" });
|
set({ user: null, status: "anonymous" });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateAccount: async (changes) => {
|
||||||
|
const res = await apiJson<{ user: AuthUser }>("/me", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
});
|
||||||
|
set({ user: res.user });
|
||||||
|
},
|
||||||
|
|
||||||
// On boot: try to silently resume a session via the refresh cookie.
|
// On boot: try to silently resume a session via the refresh cookie.
|
||||||
restore: async () => {
|
restore: async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { apiJson, getAccessToken } from "../lib/apiClient";
|
||||||
|
|
||||||
|
export interface FriendUser {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendEntry {
|
||||||
|
friendshipId: string;
|
||||||
|
user: FriendUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult extends FriendUser {
|
||||||
|
relationship:
|
||||||
|
| "none"
|
||||||
|
| "pending_out"
|
||||||
|
| "pending_in"
|
||||||
|
| "friends"
|
||||||
|
| "blocked";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FriendsState {
|
||||||
|
friends: FriendEntry[];
|
||||||
|
incoming: FriendEntry[];
|
||||||
|
outgoing: FriendEntry[];
|
||||||
|
loading: boolean;
|
||||||
|
load: () => Promise<void>;
|
||||||
|
search: (q: string) => Promise<SearchResult[]>;
|
||||||
|
sendRequest: (username: string) => Promise<void>;
|
||||||
|
accept: (friendshipId: string) => Promise<void>;
|
||||||
|
decline: (friendshipId: string) => Promise<void>;
|
||||||
|
unfriend: (friendshipId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFriendsStore = create<FriendsState>((set, get) => ({
|
||||||
|
friends: [],
|
||||||
|
incoming: [],
|
||||||
|
outgoing: [],
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
if (!getAccessToken()) {
|
||||||
|
set({ friends: [], incoming: [], outgoing: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const [friendsRes, requestsRes] = await Promise.all([
|
||||||
|
apiJson<{ friends: FriendEntry[] }>("/friends"),
|
||||||
|
apiJson<{ incoming: FriendEntry[]; outgoing: FriendEntry[] }>(
|
||||||
|
"/friends/requests",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
set({
|
||||||
|
friends: friendsRes.friends,
|
||||||
|
incoming: requestsRes.incoming,
|
||||||
|
outgoing: requestsRes.outgoing,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
search: async (q) => {
|
||||||
|
if (!getAccessToken() || q.trim().length < 2) return [];
|
||||||
|
const res = await apiJson<{ results: SearchResult[] }>(
|
||||||
|
`/friends/search?q=${encodeURIComponent(q.trim())}`,
|
||||||
|
);
|
||||||
|
return res.results;
|
||||||
|
},
|
||||||
|
|
||||||
|
sendRequest: async (username) => {
|
||||||
|
await apiJson("/friends/requests", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
await get().load();
|
||||||
|
},
|
||||||
|
|
||||||
|
accept: async (friendshipId) => {
|
||||||
|
await apiJson(`/friends/requests/${friendshipId}/accept`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
await get().load();
|
||||||
|
},
|
||||||
|
|
||||||
|
decline: async (friendshipId) => {
|
||||||
|
await apiJson(`/friends/requests/${friendshipId}/decline`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
await get().load();
|
||||||
|
},
|
||||||
|
|
||||||
|
unfriend: async (friendshipId) => {
|
||||||
|
await apiJson(`/friends/${friendshipId}`, { method: "DELETE" });
|
||||||
|
await get().load();
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -47,6 +47,7 @@ export interface Player {
|
|||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
linkedUserId?: string; // compte serveur de l'ami tagué comme co-joueur (Phase 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoundScore {
|
export interface RoundScore {
|
||||||
|
|||||||
Reference in New Issue
Block a user